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""" + + + + + Agenta ↔ MCP server + + + +
+
{icon}
+ {heading_html} + {auto_return_html} +
+ + +""" diff --git a/api/oss/src/apis/fastapi/gateways/mcps/utils.py b/api/oss/src/apis/fastapi/gateways/mcps/utils.py new file mode 100644 index 0000000000..2e79c2a3a4 --- /dev/null +++ b/api/oss/src/apis/fastapi/gateways/mcps/utils.py @@ -0,0 +1,46 @@ +"""Parses the MCP proxy's routing headers (entities.md §9). + +Header names pinned against the 2026-07-28 MCP revision +(`docs/design/gateways-research/v1/raw/mcp-2026-07-28.md`, "Header-based routing"): +`MCP-Method` is required on every Streamable HTTP POST; `MCP-Name` carries the target for +`tools/call`, `resources/read` and `prompts/get`, and is absent for target-less methods +(`tools/list`, `server/discover`, ...). The body is never parsed for routing. +""" + +from typing import Dict, Optional, Tuple + +from oss.src.core.gateways.mcps.dtos import COMPOSIO_PROVIDER, MCPCallContext + +MCP_METHOD_HEADER = "MCP-Method" +MCP_NAME_HEADER = "MCP-Name" + + +def parse_mcp_call_context(*, headers: Dict[str, str]) -> MCPCallContext: + """Read `MCP-Method`/`MCP-Name` from the caller's request headers. + + Raises ValueError when `MCP-Method` is missing or blank; the proxy translates that + into the surface's own invalid-request response. + """ + lowered = {key.lower(): value for key, value in headers.items()} + + method = (lowered.get(MCP_METHOD_HEADER.lower()) or "").strip() + if not method: + raise ValueError(f"Missing or empty required header: {MCP_METHOD_HEADER}") + + target = (lowered.get(MCP_NAME_HEADER.lower()) or "").strip() or None + + return MCPCallContext(method=method, target=target) + + +def split_builtin_path(*, provider: str, rest: str) -> Tuple[Optional[str], str]: + """Split a builtin path's remainder into (integration, name) for its provider. + + Composio addresses a connection as `{integration}/{connection}`; `agenta` serves its + own endpoints under a bare slug (D30). An unknown provider is read the same way as + agenta — resolution is what refuses it, not this parse. + """ + remainder = rest.strip("/") + if provider == COMPOSIO_PROVIDER: + integration, _, name = remainder.partition("/") + return integration or None, name + return None, remainder diff --git a/api/oss/src/apis/fastapi/gateways/utils.py b/api/oss/src/apis/fastapi/gateways/utils.py new file mode 100644 index 0000000000..9986d35126 --- /dev/null +++ b/api/oss/src/apis/fastapi/gateways/utils.py @@ -0,0 +1,39 @@ +"""Helpers shared by both gateway proxies (entities.md §9).""" + +from typing import Dict + +# Framing/transport headers ASGI (Starlette/uvicorn) computes for our own response; +# forwarding the upstream's copies verbatim would conflict with what it writes, or, for +# content-encoding, describe bytes httpx already decoded on our behalf. Starlette keeps a +# content-length it is handed, so a relayed one also outlives any body we rewrite. +_STRIPPED_RESPONSE_HEADERS = { + "content-length", + "content-encoding", + "transfer-encoding", + "connection", + "keep-alive", +} + + +def response_headers(headers: Dict[str, str]) -> Dict[str, str]: + return { + k: v for k, v in headers.items() if k.lower() not in _STRIPPED_RESPONSE_HEADERS + } + + +# A single unambiguous machine-readable marker, embedded in every TYPED refusal's `message` +# on both planes (WP25, OD18) so `code`/`cause` survives even when a harness's SDK discards +# everything else the body carries — codex-rs's `extract_error_message` keeps only +# `error.message` before reformatting, so a marker inside that one surviving field is the only +# channel left. U+27E6/U+27E7 (MATHEMATICAL LEFT/RIGHT WHITE SQUARE BRACKET) 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, and it cannot collide with +# `gateway-error.ts`'s separate `{...}` body scan. Never applied to `upstream_error` on either +# plane: D16 forwards the upstream's own detail untouched, and neither proxy may inject text +# into a body it promised not to touch. +CODE_MARKER_OPEN = "⟦agenta_code:" +CODE_MARKER_CLOSE = "⟧" + + +def with_code_marker(message: str, code: str) -> str: + return f"{message} {CODE_MARKER_OPEN}{code}{CODE_MARKER_CLOSE}" diff --git a/api/oss/src/core/access/permissions/types.py b/api/oss/src/core/access/permissions/types.py index 9c0fb3acd9..4162d9de42 100644 --- a/api/oss/src/core/access/permissions/types.py +++ b/api/oss/src/core/access/permissions/types.py @@ -175,6 +175,16 @@ class Permission(str, Enum): EDIT_MOUNTS = "edit_mounts" USE_MOUNTS = "use_mounts" + # 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" + @classmethod def default_permissions(cls, role): VIEWER_PERMISSIONS = [ @@ -201,6 +211,8 @@ def default_permissions(cls, role): cls.VIEW_TOOLS, cls.VIEW_TRIGGERS, cls.VIEW_MOUNTS, + cls.VIEW_LLM_ENDPOINTS, + cls.VIEW_MCP_ENDPOINTS, ] ANNOTATOR_PERMISSIONS = VIEWER_PERMISSIONS + [ cls.EDIT_ANNOTATIONS, @@ -212,6 +224,8 @@ def default_permissions(cls, role): cls.EDIT_SPANS, cls.RUN_TOOLS, cls.RUN_TRIGGERS, + cls.USE_LLM_ENDPOINTS, + cls.USE_MCP_ENDPOINTS, ] EDITOR_PERMISSIONS = ANNOTATOR_PERMISSIONS + [ cls.EDIT_APPLICATIONS, @@ -230,6 +244,8 @@ def default_permissions(cls, role): cls.EDIT_INVOCATIONS, cls.EDIT_TOOLS, cls.EDIT_TRIGGERS, + cls.EDIT_LLM_ENDPOINTS, + cls.EDIT_MCP_ENDPOINTS, ] DEVELOPER_PERMISSIONS = EDITOR_PERMISSIONS + [ cls.VIEW_API_KEYS, diff --git a/api/oss/src/core/events/types.py b/api/oss/src/core/events/types.py index ee7b55c55a..86c595fc22 100644 --- a/api/oss/src/core/events/types.py +++ b/api/oss/src/core/events/types.py @@ -15,6 +15,9 @@ class EventType(str, Enum): WEBHOOKS_SUBSCRIPTIONS_TESTED = "webhooks.subscriptions.tested" + # Gateway calls (D22, specs-wp4.md) — one per relay, allow or deny. + GATEWAYS_CALLED = "gateways.called" + # Tracing reads TRACES_FETCHED = "traces.fetched" TRACES_QUERIED = "traces.queried" diff --git a/api/oss/src/core/gateways/__init__.py b/api/oss/src/core/gateways/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/core/gateways/dtos.py b/api/oss/src/core/gateways/dtos.py new file mode 100644 index 0000000000..8579210c17 --- /dev/null +++ b/api/oss/src/core/gateways/dtos.py @@ -0,0 +1,102 @@ +"""Shared vocabulary for both gateway planes (entities.md §4.1). + +The gateways are a separate domain from `core/gateway/` (the integrations surface) and +define their own copies of the auth-scheme / connection-state vocabulary rather than +importing the existing triplicate copies in `core/gateway/connections/dtos.py`, +`core/tools/dtos.py` and `core/triggers/dtos.py` (OR4). +""" + +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +# The header that authenticates the caller INTO the gateway (D31). It is ours and is +# stripped on both planes before any relay. Nothing else of the caller's is: the data plane +# reads no other header as ours, so `Authorization` is the caller's own and reaches the +# upstream unless a resolved secret overwrites it — which is pass-through (OD15). +GATEWAY_ONLY_HEADERS = frozenset({"x-ag-credentials"}) + + +class GatewayAuthScheme(str, Enum): + """How an upstream authenticates us. The gateways' own copy (OR4, §4.1).""" + + OAUTH = "oauth" + API_KEY = "api_key" + NONE = "none" + + +class GatewayConnectionState(str, Enum): + """Derived per caller at read time — never stored (§2.6).""" + + READY = "ready" # a usable secret exists for this owner + NEEDS_AUTH = "needs_auth" # OAuth target with no usable secret; connect + NEEDS_INPUT = "needs_input" # a secret must be supplied before use + + +class GatewayConnectAffordance(BaseModel): + """The call to make when a secret is missing — an interaction, not a + failure (D17). Same shape as the tools domain's ConnectAffordance.""" + + endpoint: str + body: Dict[str, Any] = Field(default_factory=dict) + + +class GatewayConnectionRequirement(BaseModel): + """One target's secret state, returned from discovery and from a refused + call. `connect` is present exactly when the state is not READY.""" + + target: str # route path under the plane, e.g. "builtin/composio/notion/my-notion" + state: GatewayConnectionState + connect: Optional[GatewayConnectAffordance] = None + + +class GatewayEndpointNamespace(str, Enum): + """The first URL segment under either plane — the same three words on both + (§2.3, D30). The namespace selects the backend, and splits on whose secret pays: + builtin is ours and bills through us, standard and custom are the user's.""" + + BUILTIN = "builtin" # our account; a provider segment follows (agenta, composio) + STANDARD = "standard" # a known shape, the user's secret; generated, never a row + CUSTOM = "custom" # a row; configurable + + +class GatewayEndpointRoute(BaseModel): + """Where and how to dial an upstream — the two fields both planes share (§2.4). + `headers` is addressing, never a secret: `Authorization` is derived from the + resolved secret and overwrites whatever is set here (§7.2).""" + + base_url: Optional[str] = None + headers: Optional[Dict[str, str]] = None + + +class GatewayEndpointFilter(BaseModel): + """One name filter, the same shape for LLM models and MCP tools (§2.4). + + Absent list = no constraint from that side; `allowlist: []` refuses everything; + `denylist` always wins. Exact names only — a glob syntax would need its own + decision on both planes at once. + """ + + allowlist: Optional[List[str]] = None + denylist: Optional[List[str]] = None + + def allows(self, name: str) -> bool: + if self.denylist is not None and name in self.denylist: + return False + if self.allowlist is None: + return True + return name in self.allowlist + + def enumerate(self) -> List[str]: + """What can be listed, which is only ever the allowlist minus the denylist — + with no allowlist the gateway does not know the upstream's catalogue (§2.4).""" + return [name for name in (self.allowlist or []) if self.allows(name)] + + +class GatewayEndpointSettings(BaseModel): + """Per-endpoint settings, one concern for both planes (D21). Custom endpoints + only; generated endpoints take the code defaults.""" + + timeout_seconds: Optional[float] = None diff --git a/api/oss/src/core/gateways/llms/__init__.py b/api/oss/src/core/gateways/llms/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/core/gateways/llms/catalog.py b/api/oss/src/core/gateways/llms/catalog.py new file mode 100644 index 0000000000..33cb29bf4c --- /dev/null +++ b/api/oss/src/core/gateways/llms/catalog.py @@ -0,0 +1,81 @@ +"""The generated standard-endpoint catalogue (entities.md §8, D20). + +Two pure functions over the SDK's static provider->model map. A standard endpoint is +derived, never stored: no id, no `Lifecycle`, code-default `settings`. Existence for a given +project is answered by the resolver's `available_provider_keys` (R2), not by this module — +`standard_llm_endpoint` never queries a DAO or the vault. +""" + +from typing import List, Optional + +from agenta.sdk.utils.assets import litellm_provider_prefixes, supported_llm_models + +from oss.src.core.gateways.dtos import GatewayEndpointNamespace +from oss.src.core.gateways.llms.dtos import ( + LLMDeploymentKind, + LLMEndpoint, + LLMEndpointData, + LLMEndpointRoute, + LLMModelFilter, +) +from oss.src.core.gateways.llms.providers.passthrough.routing import DIRECT_BASE_URLS +from oss.src.core.shared.dtos import Header + + +def _bare_model_id(*, provider_key: str, model_id: str) -> str: + """Strip the provider's own litellm routing prefix, if the catalogued id carries one. + + litellm's `"anthropic/claude-sonnet-5"`-style ids exist for litellm's own dispatch and + mean nothing to the upstream itself (open-designs.md OD16) — a relay that never touches + the body must advertise the id the real upstream accepts, since fixing this at relay + time would be the body conversion D34 forbids. + """ + prefix = litellm_provider_prefixes.get(provider_key) + if prefix and model_id.startswith(f"{prefix}/"): + return model_id[len(prefix) + 1 :] + return model_id + + +def standard_llm_endpoint(*, provider_key: str) -> Optional[LLMEndpoint]: + """The generated endpoint for one provider, or None when `provider_key` has no + entry in `supported_llm_models` — covers both an unknown string and the three + `StandardProviderKind` members with no catalogue entry (`anyscale`, `alephalpha`, + `mistralai`).""" + model_slugs = supported_llm_models.get(provider_key) + if model_slugs is None: + return None + + return LLMEndpoint( + slug=provider_key, + header=Header(name=provider_key), + provider_key=provider_key, + deployment_kind=LLMDeploymentKind.DIRECT, + namespace=GatewayEndpointNamespace.STANDARD, + data=LLMEndpointData( + route=_route(provider_key), + models=LLMModelFilter( + allowlist=[ + _bare_model_id(provider_key=provider_key, model_id=model_id) + for model_id in model_slugs + ] + ), + ), + ) + + +def _route(provider_key: str) -> LLMEndpointRoute: + """Every DIRECT provider OD16 clears has a known base_url (open-designs.md); one absent + from the table is one OD16 did not clear, and relaying to it fails at relay time rather + than here — this function never raises.""" + base_url = DIRECT_BASE_URLS.get(provider_key) + return LLMEndpointRoute(base_url=base_url) if base_url else LLMEndpointRoute() + + +def standard_llm_endpoints() -> List[LLMEndpoint]: + """All eleven, existence-unfiltered — the service intersects with the project's + provider keys (D20).""" + endpoints = ( + standard_llm_endpoint(provider_key=provider_key) + for provider_key in supported_llm_models + ) + return [endpoint for endpoint in endpoints if endpoint is not None] diff --git a/api/oss/src/core/gateways/llms/dtos.py b/api/oss/src/core/gateways/llms/dtos.py new file mode 100644 index 0000000000..529f36a63e --- /dev/null +++ b/api/oss/src/core/gateways/llms/dtos.py @@ -0,0 +1,146 @@ +"""The LLM plane's DTOs (entities.md §4.3).""" + +from enum import Enum +from typing import Any, Dict, Optional +from uuid import UUID + +from pydantic import BaseModel, Field + +from oss.src.core.gateways.dtos import ( + GatewayEndpointFilter, + GatewayEndpointNamespace, + GatewayEndpointRoute, + GatewayEndpointSettings, +) +from oss.src.core.shared.dtos import ( + Header, + Identifier, + Lifecycle, + Metadata, + Slug, + Status, +) + + +class LLMDeploymentKind(str, Enum): + """How a provider is reached — the wire's `deployment_kind` axis, aligned with + CustomProviderKind in core/secrets/enums.py (`models.md`: keep both axes).""" + + DIRECT = "direct" + CUSTOM = "custom" # OpenAI-compatible third party or self-hosted + AZURE = "azure" + BEDROCK = "bedrock" + SAGEMAKER = "sagemaker" + VERTEX = "vertex_ai" + MOCK = "mock" # the in-process test double (D23) — a deployment kind, not a provider name + + +class LLMEndpointRoute(GatewayEndpointRoute): + """The shared route plus what only a provider deployment needs, mirroring the runner + wire's `endpoint` object (services/runner/src/protocol.ts): apiVersion for Azure, + region for AWS and Vertex. Which fields matter is decided by `deployment_kind` — an + `api_version` on Bedrock is ignored, not an error.""" + + api_version: Optional[str] = None + region: Optional[str] = None + extras: Optional[Dict[str, Any]] = None + """Non-secret addressing a deployment needs and no named field carries — + `vertex_project`, `aws_bedrock_runtime_endpoint`, `aws_role_name`. Same rule as + `headers`: addressing, never secret material, which stays in the secret's own + `extras` and outranks this on collision (§2.4).""" + + +# The LLM plane's name for the shared filter. Same shape, same storage. +LLMModelFilter = GatewayEndpointFilter + + +class LLMEndpointSettings(GatewayEndpointSettings): + max_output_tokens: Optional[int] = ( + None # ceiling (D21); rejected, never clamped (D25) + ) + + +class LLMEndpointData(BaseModel): + route: LLMEndpointRoute = Field(default_factory=LLMEndpointRoute) + models: LLMModelFilter = Field(default_factory=LLMModelFilter) + settings: LLMEndpointSettings = Field(default_factory=LLMEndpointSettings) + + +class LLMEndpointFlags(BaseModel): + is_active: bool = True + # no is_valid: an endpoint does not authenticate; secret health lives + # with the secret (§2.6) + + +class LLMEndpoint(Identifier, Slug, Header, Lifecycle, Metadata): + # Nullable (entities.md §2.4): with the passthrough/translated split gone (D34), a + # stored row's provider_key decides nothing — it is a label, not a routing input. A + # custom row pointed at a self-hosted gateway names no provider that means anything. + provider_key: Optional[str] = None + deployment_kind: LLMDeploymentKind + namespace: GatewayEndpointNamespace = GatewayEndpointNamespace.CUSTOM + secret_id: Optional[UUID] = None + # + data: LLMEndpointData = Field(default_factory=LLMEndpointData) + flags: LLMEndpointFlags = Field(default_factory=LLMEndpointFlags) + status: Optional[Status] = None + + +class LLMEndpointCreate(Slug, Header, Metadata): + provider_key: Optional[str] = None + deployment_kind: LLMDeploymentKind + secret_id: Optional[UUID] = None + # + data: LLMEndpointData = Field(default_factory=LLMEndpointData) + flags: LLMEndpointFlags = Field(default_factory=LLMEndpointFlags) + + +class LLMEndpointEdit(Identifier, Header, Metadata): + # no provider_key, no deployment_kind: repointing an endpoint at a different + # provider family is a different endpoint, not an edit (the channels rule) + secret_id: Optional[UUID] = None + # + data: LLMEndpointData = Field(default_factory=LLMEndpointData) + flags: LLMEndpointFlags = Field(default_factory=LLMEndpointFlags) + + +class LLMEndpointQuery(BaseModel): + provider_key: Optional[str] = None + deployment_kind: Optional[LLMDeploymentKind] = None + slug: Optional[str] = None + + +class LLMProtocol(str, Enum): + """The front door a call arrived through (D33). `model`/`stream` share field + names across all three wires; this tag exists so the ceiling check can bind + to the right request field without guessing (D34, WP23).""" + + CHAT_COMPLETIONS = "chat_completions" + RESPONSES = "responses" + MESSAGES = "messages" + + +class LLMCallContext(BaseModel): + """What policy needs from the request body — parsed minimally, so the body + itself can relay byte for byte (`scope-checklist.md`).""" + + model: str + stream: bool = False + protocol: LLMProtocol = LLMProtocol.CHAT_COMPLETIONS + + +class LLMResolvedRoute(BaseModel): + """What the south port receives: the route after selection, with the model + id already in the routing library's form.""" + + provider_key: Optional[str] = None + 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 + extras: Optional[Dict[str, Any]] = None + # + settings: LLMEndpointSettings = Field(default_factory=LLMEndpointSettings) diff --git a/api/oss/src/core/gateways/llms/interfaces.py b/api/oss/src/core/gateways/llms/interfaces.py new file mode 100644 index 0000000000..b1d949a8f3 --- /dev/null +++ b/api/oss/src/core/gateways/llms/interfaces.py @@ -0,0 +1,145 @@ +"""LLM plane DAO interface and south port (entities.md §7, §7.1). + +The registry lives in `registry.py`, per §0's file layout. §7.1 shows it in the same code +block as the port, which is presentation, not placement (R13). + +DAOs open their own sessions; services never touch the engine. `project_id` is first on +every method (tenant scope is structural); `user_id` on writes only. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import AsyncIterator, Dict, List, Optional +from uuid import UUID + +from oss.src.core.gateways.llms.dtos import ( + LLMCallContext, + LLMEndpoint, + LLMEndpointCreate, + LLMEndpointEdit, + LLMEndpointQuery, + LLMResolvedRoute, +) +from oss.src.core.gateways.policy.dtos import GatewayUsage, ResolvedSecret +from oss.src.core.shared.dtos import Windowing + + +class LLMEndpointsDAOInterface(ABC): + """Persistence contract for custom LLM endpoints. Standard endpoints are + generated (D20) and never pass through this interface — the service merges + them in from catalog.py, which is why nothing here has a namespace + parameter: every row is custom by construction (§2.3).""" + + @abstractmethod + async def create_endpoint( + self, + *, + project_id: UUID, + user_id: UUID, + # + endpoint: LLMEndpointCreate, + ) -> Optional[LLMEndpoint]: + """Insert. Raises EntityCreationConflict on a slug collision — the one + exception a create surfaces, per the connections DAO discipline.""" + raise NotImplementedError + + @abstractmethod + async def fetch_endpoint( + self, + *, + project_id: UUID, + # + endpoint_id: UUID, + ) -> Optional[LLMEndpoint]: + raise NotImplementedError + + @abstractmethod + async def fetch_endpoint_by_slug( + self, + *, + project_id: UUID, + # + slug: str, + ) -> Optional[LLMEndpoint]: + """The data-plane route lookup (§2.3). Backed by + uq_llms_endpoints_project_slug, so at most one row by + construction. None means the custom namespace has no such name — the + proxy 404s in the surface's own error shape (§9).""" + raise NotImplementedError + + @abstractmethod + async def edit_endpoint( + self, + *, + project_id: UUID, + user_id: UUID, + # + endpoint: LLMEndpointEdit, + ) -> Optional[LLMEndpoint]: + """Full PUT over the editable surface (§4.3): data, flags, header, + secret_id. provider_key and deployment_kind are absent from the Edit DTO and + therefore untouchable here.""" + raise NotImplementedError + + @abstractmethod + async def delete_endpoint( + self, + *, + project_id: UUID, + # + endpoint_id: UUID, + ) -> bool: + raise NotImplementedError + + @abstractmethod + async def query_endpoints( + self, + *, + project_id: UUID, + # + endpoint: Optional[LLMEndpointQuery] = None, + # + windowing: Optional[Windowing] = None, + ) -> List[LLMEndpoint]: + raise NotImplementedError + + +# --- the south port ---------------------------------------------------------- # + + +@dataclass +class LLMRelayResult: + """One upstream answer, streaming or not. `body` yields exactly one chunk + for a non-streaming call. `usage` is populated by the adapter once `body` + is exhausted, when the upstream exposed it (the OpenAI stream carries a + trailing usage frame; the translated adapter reports the library's count); + None means unknowable, and the audit event says so rather than guessing.""" + + status_code: int + headers: Dict[str, str] + body: AsyncIterator[bytes] + usage: Optional[GatewayUsage] = None + + +class LLMUpstreamInterface(ABC): + """Turns a resolved route plus a resolved secret into an upstream call. + The core never imports an implementation; wiring happens at the entrypoint.""" + + @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.""" + raise NotImplementedError + + # async def relay_embedding(...) -> LLMRelayResult — deferred with the evaluator path (D15) diff --git a/api/oss/src/core/gateways/llms/providers/mock/__init__.py b/api/oss/src/core/gateways/llms/providers/mock/__init__.py new file mode 100644 index 0000000000..f95feb5b3f --- /dev/null +++ b/api/oss/src/core/gateways/llms/providers/mock/__init__.py @@ -0,0 +1 @@ +"""The mock LLM upstream (D23, WP5): MockLLMAdapter and its deployable app.""" diff --git a/api/oss/src/core/gateways/llms/providers/mock/adapter.py b/api/oss/src/core/gateways/llms/providers/mock/adapter.py new file mode 100644 index 0000000000..eb7c0b022a --- /dev/null +++ b/api/oss/src/core/gateways/llms/providers/mock/adapter.py @@ -0,0 +1,289 @@ +"""MockLLMAdapter: the in-process mock LLM upstream (entities.md §7.1, D23). + +No socket, no process. Registered once, statically, under the "mock" adapter key +(wiring block, entities.md §9). Controllable behavior is keyed by `context.model`, +checked as a prefix so the base model name stays free-form: + + mock/echo (default; any name matching no other suffix below) + mock/error raises LLMUpstreamError + mock/slow-{n} sleeps n seconds, then answers like mock/echo + +`context.protocol` (D33, WP23) picks the response shape — Chat Completions, OpenAI +Responses or Anthropic Messages — so the three front doors each have something protocol- +shaped to relay in tests, without any of this adapter's logic branching on the door that +called it beyond this one dispatch. + +The deployable app (app.py) calls this same adapter, so both tiers share one +implementation of the control convention. +""" + +import asyncio +import json +import re +import time +import uuid +from typing import Any, AsyncIterator, Dict, Optional + +from oss.src.core.gateways.llms.dtos import ( + LLMCallContext, + LLMProtocol, + LLMResolvedRoute, +) +from oss.src.core.gateways.llms.interfaces import LLMRelayResult, LLMUpstreamInterface +from oss.src.core.gateways.llms.types import LLMUpstreamError +from oss.src.core.gateways.policy.dtos import GatewayUsage, ResolvedSecret + +_ERROR_PREFIX = "mock/error" +_SLOW_RE = re.compile(r"^mock/slow-(\d+)") + + +def _parse_slow_seconds(model: str) -> Optional[int]: + match = _SLOW_RE.match(model) + return int(match.group(1)) if match else None + + +def _last_message_content(body: bytes) -> str: + try: + payload = json.loads(body) if body else {} + except (json.JSONDecodeError, TypeError): + return "" + + # "messages" (Chat Completions, Messages) or "input" (Responses) — same shape, + # different field name on the wire. + messages = payload.get("messages") or payload.get("input") or [] + if isinstance(messages, str): + return messages + if not messages: + return "" + + content = messages[-1].get("content", "") + if isinstance(content, str): + return content + if isinstance(content, list): + return "".join( + part.get("text", "") for part in content if isinstance(part, dict) + ) + return str(content) + + +def _word_count(text: str) -> int: + return len(text.split()) + + +def _completion_payload( + *, completion_id: str, created: int, model: str, content: str +) -> Dict[str, Any]: + return { + "id": completion_id, + "object": "chat.completion", + "created": created, + "model": model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + } + ], + } + + +def _chunk_payload( + *, completion_id: str, created: int, model: str, delta: Dict[str, Any], finish +) -> Dict[str, Any]: + return { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [{"index": 0, "delta": delta, "finish_reason": finish}], + } + + +def _responses_payload( + *, response_id: str, created: int, model: str, content: str +) -> Dict[str, Any]: + return { + "id": response_id, + "object": "response", + "created_at": created, + "model": model, + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": content}], + } + ], + } + + +def _messages_payload(*, message_id: str, model: str, content: str) -> Dict[str, Any]: + return { + "id": message_id, + "type": "message", + "role": "assistant", + "model": model, + "content": [{"type": "text", "text": content}], + "stop_reason": "end_turn", + } + + +def _sse(payload: Dict[str, Any]) -> bytes: + return f"data: {json.dumps(payload)}\n\n".encode() + + +def _sse_event(event: str, payload: Dict[str, Any]) -> bytes: + return f"event: {event}\ndata: {json.dumps(payload)}\n\n".encode() + + +async def _empty_body() -> AsyncIterator[bytes]: + return + yield b"" # pragma: no cover — placeholder, makes this an async generator + + +class MockLLMAdapter(LLMUpstreamInterface): + """The mock upstream (D23): unauthenticated, in-process, never opens a + socket. `secret` may be None — targets with GatewayAuthScheme.NONE are + the intended callers (entities.md §2).""" + + async def relay_chat_completion( + self, + *, + route: LLMResolvedRoute, + secret: Optional[ResolvedSecret], + # + context: LLMCallContext, + body: bytes, + headers: Dict[str, str], + ) -> LLMRelayResult: + model = context.model + + if model.startswith(_ERROR_PREFIX): + raise LLMUpstreamError( + provider_key="mock", status_code=500, detail="forced by mock/error" + ) + + slow_seconds = _parse_slow_seconds(model) + if slow_seconds is not None: + await asyncio.sleep(slow_seconds) + + content = _last_message_content(body) + input_tokens = _word_count(body.decode(errors="replace")) if body else 0 + output_tokens = _word_count(content) + completion_id = f"chatcmpl-mock-{uuid.uuid4().hex}" + created = int(time.time()) + + result = LLMRelayResult( + status_code=200, + headers={ + "content-type": ( + "text/event-stream" if context.stream else "application/json" + ) + }, + body=_empty_body(), + ) + + async def _body_iter() -> AsyncIterator[bytes]: + if context.protocol == LLMProtocol.RESPONSES: + usage = { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens, + } + if context.stream: + yield _sse_event( + "response.output_text.delta", + {"type": "response.output_text.delta", "delta": content}, + ) + completed = _responses_payload( + response_id=completion_id, + created=created, + model=model, + content=content, + ) + completed["usage"] = usage + yield _sse_event( + "response.completed", + {"type": "response.completed", "response": completed}, + ) + else: + payload = _responses_payload( + response_id=completion_id, + created=created, + model=model, + content=content, + ) + payload["usage"] = usage + yield json.dumps(payload).encode() + + elif context.protocol == LLMProtocol.MESSAGES: + usage = {"input_tokens": input_tokens, "output_tokens": output_tokens} + if context.stream: + yield _sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": content}, + }, + ) + yield _sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": usage, + }, + ) + yield _sse_event("message_stop", {"type": "message_stop"}) + else: + payload = _messages_payload( + message_id=completion_id, model=model, content=content + ) + payload["usage"] = usage + yield json.dumps(payload).encode() + + else: + if context.stream: + yield _sse( + _chunk_payload( + completion_id=completion_id, + created=created, + model=model, + delta={"role": "assistant", "content": content}, + finish=None, + ) + ) + yield _sse( + _chunk_payload( + completion_id=completion_id, + created=created, + model=model, + delta={}, + finish="stop", + ) + ) + yield b"data: [DONE]\n\n" + else: + payload = _completion_payload( + completion_id=completion_id, + created=created, + model=model, + content=content, + ) + payload["usage"] = { + "prompt_tokens": input_tokens, + "completion_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens, + } + yield json.dumps(payload).encode() + + result.usage = GatewayUsage( + calls=1, + input_tokens=input_tokens, + output_tokens=output_tokens, + cost=0.0, + ) + + result.body = _body_iter() + return result diff --git a/api/oss/src/core/gateways/llms/providers/mock/app.py b/api/oss/src/core/gateways/llms/providers/mock/app.py new file mode 100644 index 0000000000..9cef4c08d6 --- /dev/null +++ b/api/oss/src/core/gateways/llms/providers/mock/app.py @@ -0,0 +1,104 @@ +"""Deployable mock OpenAI-compatible LLM server (entities.md §0, D23, WP5). + +A standalone ASGI app (`uvicorn oss.src.core.gateways.llms.providers.mock.app:app`), +not mounted into the main API process. It terminates a real HTTP connection and a +real socket, which is what the in-process `MockLLMAdapter` cannot exercise (SSE +framing over the wire, a genuine hang under a client-side timeout) — Checkpoint A's +acceptance suite needs this running as its own compose service. + +Delegates every request straight to `MockLLMAdapter` so both tiers share one +implementation of the control convention: a test written against the in-process +adapter and a test written against this process see identical behavior for the +same input. +""" + +import json + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, Response, StreamingResponse + +from oss.src.core.gateways.llms.dtos import ( + LLMCallContext, + LLMDeploymentKind, + LLMProtocol, + LLMResolvedRoute, +) +from oss.src.core.gateways.llms.providers.mock.adapter import MockLLMAdapter +from oss.src.core.gateways.llms.types import LLMUpstreamError + +app = FastAPI(title="agenta-mock-llm-gateway") +_adapter = MockLLMAdapter() + + +@app.get("/health") +async def health() -> Response: + return Response(status_code=200) + + +@app.post("/__echo/v1/chat/completions") +async def echo_headers(request: Request) -> Response: + """Report the headers this process received (launch-2.md D39). + + On the completions path rather than a bare route so it is reachable THROUGH the gateway: + an endpoint whose `base_url` ends in `/__echo` relays here, and the answer is the only + proof that `X-AG-Credentials` was stripped and a passed-through `Authorization` arrived. + """ + return JSONResponse(content={"headers": dict(request.headers)}) + + +async def _relay(request: Request, *, protocol: LLMProtocol) -> Response: + body = await request.body() + try: + payload = json.loads(body) if body else {} + except (json.JSONDecodeError, TypeError): + payload = {} + + model = payload.get("model") or "mock/echo" + stream = bool(payload.get("stream", False)) + context = LLMCallContext(model=model, stream=stream, protocol=protocol) + route = LLMResolvedRoute( + provider_key="mock", deployment_kind=LLMDeploymentKind.MOCK, model=model + ) + + try: + result = await _adapter.relay_chat_completion( + route=route, secret=None, context=context, body=body, headers={} + ) + except LLMUpstreamError as exc: + return JSONResponse( + status_code=exc.status_code or 500, + content={ + "error": { + "message": exc.detail or str(exc), + "type": "server_error", + "code": "mock_upstream_error", + } + }, + ) + + if stream: + return StreamingResponse( + result.body, media_type="text/event-stream", status_code=result.status_code + ) + + chunks = [chunk async for chunk in result.body] + return Response( + content=b"".join(chunks), + media_type="application/json", + status_code=result.status_code, + ) + + +@app.post("/v1/chat/completions") +async def chat_completions(request: Request) -> Response: + return await _relay(request, protocol=LLMProtocol.CHAT_COMPLETIONS) + + +@app.post("/v1/responses") +async def responses(request: Request) -> Response: + return await _relay(request, protocol=LLMProtocol.RESPONSES) + + +@app.post("/v1/messages") +async def messages(request: Request) -> Response: + return await _relay(request, protocol=LLMProtocol.MESSAGES) diff --git a/api/oss/src/core/gateways/llms/providers/passthrough/__init__.py b/api/oss/src/core/gateways/llms/providers/passthrough/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/core/gateways/llms/providers/passthrough/adapter.py b/api/oss/src/core/gateways/llms/providers/passthrough/adapter.py new file mode 100644 index 0000000000..f7bdbc8bee --- /dev/null +++ b/api/oss/src/core/gateways/llms/providers/passthrough/adapter.py @@ -0,0 +1,244 @@ +"""RelayLLMAdapter: the one south-port relay (D34, entities.md §7.1). + +D34 forbids body conversion, so there is one relay for every deployment kind — the +`passthrough`/`translated` split it replaces. `routing.py` composes the URL from route +fields; `auth.py` presents the secret. Neither ever parses `body`. The response body is read +only to lift `usage` for the audit record; the bytes handed back to the caller are never +reconstructed from that parse. + +D40 amends D34 with one bounded exception: `static_fields.py`'s literal per-deployment table, +applied to the request body immediately before it is sent, for Vertex only (OD19 moved +Bedrock's Messages door to `bedrock-mantle`, which needs no rewrite). Every other +deployment's request body still travels untouched. +""" + +import json +from typing import Any, AsyncIterator, Dict, Optional + +import httpx + +from oss.src.core.gateways.dtos import GATEWAY_ONLY_HEADERS +from oss.src.core.gateways.llms.dtos import ( + LLMCallContext, + LLMProtocol, + LLMResolvedRoute, +) +from oss.src.core.gateways.llms.interfaces import LLMRelayResult, LLMUpstreamInterface +from oss.src.core.gateways.llms.providers.passthrough.auth import build_auth_headers +from oss.src.core.gateways.llms.providers.passthrough.routing import build_url +from oss.src.core.gateways.llms.providers.passthrough.static_fields import ( + apply_static_fields, +) +from oss.src.core.gateways.llms.types import LLMUpstreamError +from oss.src.core.gateways.policy.dtos import GatewayUsage, ResolvedSecret + +# No document pins a default (specs-wp6.md: "Missing from the design, needs a ruling"). +# 60s matches the outbound timeout core/workflows/service.py already uses for its own +# httpx calls (_post_service_json) — this package's own call, not a transcribed number. +_DEFAULT_TIMEOUT_SECONDS = 60.0 + +# Stripped from the outbound header set: hop-by-hop headers (RFC 7230 §6.1) plus our own +# credentials header. A caller's `Authorization` is forwarded and overwritten below only +# when a secret resolved — pass-through is what happens when nothing overwrites (OD15). +_STRIPPED_HEADERS = { + *GATEWAY_ONLY_HEADERS, + "host", + "content-length", + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "proxy-authenticate", + "proxy-authorization", +} + + +async def _outbound_headers( + *, + headers: Dict[str, str], + route: LLMResolvedRoute, + secret: Optional[ResolvedSecret], +) -> Dict[str, str]: + outbound = {k: v for k, v in headers.items() if k.lower() not in _STRIPPED_HEADERS} + if route.headers: + outbound.update(route.headers) + outbound.update(await build_auth_headers(route, secret)) + return outbound + + +# Enough to hold the last SSE frames; the usage frame is the final data frame before the +# stream's own terminator (`[DONE]` for Chat Completions, `message_stop`/`response.completed` +# for the other two). +_USAGE_TAIL_BYTES = 8192 + + +# Chat Completions names token counts `prompt_tokens`/`completion_tokens`; Responses and +# Messages both name them `input_tokens`/`output_tokens` (D33, WP23) — the one place this +# adapter branches on protocol, and only to read, never to rewrite. +def _usage_from_payload(payload: Any, protocol: LLMProtocol) -> Optional[GatewayUsage]: + if not isinstance(payload, dict): + return None + + usage = payload.get("usage") + if usage is None and protocol == LLMProtocol.RESPONSES: + # A Responses stream's usage rides the terminal `response.completed` event, + # nested under `response` rather than at the frame's top level. + response = payload.get("response") + usage = response.get("usage") if isinstance(response, dict) else None + + if not isinstance(usage, dict): + return None + + if protocol == LLMProtocol.CHAT_COMPLETIONS: + return GatewayUsage( + calls=1, + input_tokens=usage.get("prompt_tokens"), + output_tokens=usage.get("completion_tokens"), + ) + return GatewayUsage( + calls=1, + input_tokens=usage.get("input_tokens"), + output_tokens=usage.get("output_tokens"), + ) + + +def _usage_from_stream_tail( + tail: bytes, protocol: LLMProtocol +) -> Optional[GatewayUsage]: + for line in reversed(tail.split(b"\n")): + line = line.strip() + if not line.startswith(b"data:"): + continue + chunk = line[len(b"data:") :].strip() + if chunk == b"[DONE]": + continue + try: + payload = json.loads(chunk) if chunk else None + except (json.JSONDecodeError, TypeError): + continue + usage = _usage_from_payload(payload, protocol) + if usage is not None: + return usage + return None + + +def _usage_from_body(content: bytes, protocol: LLMProtocol) -> Optional[GatewayUsage]: + try: + payload: Any = json.loads(content) if content else None + except (json.JSONDecodeError, TypeError): + return None + return _usage_from_payload(payload, protocol) + + +class RelayLLMAdapter(LLMUpstreamInterface): + """Relays to any upstream a front door's protocol reaches (OD16), with the body + forwarded verbatim and only routing/authentication applied. One `httpx.AsyncClient` + per adapter instance, reused across calls (connection pooling; a streaming response + keeps the client alive past this method's return, so it cannot be opened and closed + per call).""" + + def __init__(self, *, client: Optional[httpx.AsyncClient] = None) -> None: + self._client = client or httpx.AsyncClient() + + async def relay_chat_completion( + self, + *, + route: LLMResolvedRoute, + secret: Optional[ResolvedSecret], + # + context: LLMCallContext, + body: bytes, + headers: Dict[str, str], + ) -> LLMRelayResult: + url = build_url(route, context.protocol, stream=context.stream) + body = apply_static_fields( + deployment_kind=route.deployment_kind, + protocol=context.protocol, + body=body, + ) + outbound_headers = await _outbound_headers( + headers=headers, route=route, secret=secret + ) + timeout = ( + route.settings.timeout_seconds + if route.settings.timeout_seconds is not None + else _DEFAULT_TIMEOUT_SECONDS + ) + + request = self._client.build_request( + "POST", url, content=body, headers=outbound_headers, timeout=timeout + ) + + try: + response = await self._client.send(request, stream=True) + except httpx.TimeoutException as exc: + raise LLMUpstreamError( + provider_key=route.provider_key, + status_code=None, + detail="upstream timed out", + ) from exc + except httpx.HTTPError as exc: + raise LLMUpstreamError( + provider_key=route.provider_key, + status_code=None, + detail=str(exc), + ) from exc + + if response.status_code >= 500: + detail = (await response.aread()).decode(errors="replace") + await response.aclose() + raise LLMUpstreamError( + provider_key=route.provider_key, + status_code=response.status_code, + detail=detail, + ) + + result = LLMRelayResult( + status_code=response.status_code, + headers=dict(response.headers), + body=_empty_body(), + ) + result.body = ( + self._stream_body( + response=response, result=result, protocol=context.protocol + ) + if context.stream + else self._single_chunk_body( + response=response, result=result, protocol=context.protocol + ) + ) + return result + + @staticmethod + async def _single_chunk_body( + *, response: httpx.Response, result: LLMRelayResult, protocol: LLMProtocol + ) -> AsyncIterator[bytes]: + try: + content = await response.aread() + yield content + result.usage = _usage_from_body(content, protocol) + finally: + await response.aclose() + + @staticmethod + async def _stream_body( + *, response: httpx.Response, result: LLMRelayResult, protocol: LLMProtocol + ) -> AsyncIterator[bytes]: + # SSE chunk boundaries pass through as httpx yields them — never + # recombined or re-chunked (specs-wp6.md). Chunks are only inspected for the + # trailing usage frame; what is yielded is always the original bytes. + tail = b"" + try: + async for chunk in response.aiter_bytes(): + tail = (tail + chunk)[-_USAGE_TAIL_BYTES:] + yield chunk + finally: + result.usage = _usage_from_stream_tail(tail, protocol) or result.usage + await response.aclose() + + +async def _empty_body() -> AsyncIterator[bytes]: + return + yield b"" # pragma: no cover — placeholder, makes this an async generator diff --git a/api/oss/src/core/gateways/llms/providers/passthrough/auth.py b/api/oss/src/core/gateways/llms/providers/passthrough/auth.py new file mode 100644 index 0000000000..092ec38dbe --- /dev/null +++ b/api/oss/src/core/gateways/llms/providers/passthrough/auth.py @@ -0,0 +1,153 @@ +"""Authentication strategies (D34, OD16): present the secret without touching the body. + +One async function per `deployment_kind` — async because Vertex mints a token (real I/O); +the rest just format a header. Each returns the headers to merge over the caller's own and +the route's `headers`, last, so the resolved secret always outranks both (entities.md §2.4). +""" + +from typing import Callable, Coroutine, Dict, Optional, Tuple + +from oss.src.core.gateways.llms.dtos import LLMDeploymentKind, LLMResolvedRoute +from oss.src.core.gateways.llms.types import LLMUpstreamError +from oss.src.core.gateways.policy.dtos import ResolvedSecret +from oss.src.core.secrets.enums import SecretKind + +# provider_key -> (header name, value prefix) for a DIRECT provider whose auth header is not +# "Authorization: Bearer " (OD16). Every provider absent from this table uses the default. +_DIRECT_AUTH_HEADERS: Dict[str, Tuple[str, str]] = { + "anthropic": ("x-api-key", ""), +} +_DEFAULT_AUTH_HEADER: Tuple[str, str] = ("Authorization", "Bearer ") + + +def _secret_key(secret: ResolvedSecret) -> Optional[str]: + data = secret.secret.data + if secret.secret.kind in (SecretKind.PROVIDER_KEY, SecretKind.CUSTOM_PROVIDER): + return data.provider.key + return None + + +def _secret_extras(secret: ResolvedSecret) -> dict: + data = secret.secret.data + if secret.secret.kind == SecretKind.CUSTOM_PROVIDER: + return data.provider.extras or {} + return {} + + +async def _direct_auth( + route: LLMResolvedRoute, secret: Optional[ResolvedSecret] +) -> Dict[str, str]: + if secret is None: + return {} + key = _secret_key(secret) + if not key: + return {} + header, prefix = _DIRECT_AUTH_HEADERS.get(route.provider_key, _DEFAULT_AUTH_HEADER) + return {header: f"{prefix}{key}"} + + +async def _custom_auth( + route: LLMResolvedRoute, secret: Optional[ResolvedSecret] +) -> Dict[str, str]: + if secret is None: + return {} + headers: Dict[str, str] = {} + extras = _secret_extras(secret) + if extras: + headers.update({str(k): str(v) for k, v in extras.items()}) + key = _secret_key(secret) + if key: + headers["Authorization"] = f"Bearer {key}" + return headers + + +async def _azure_auth( + route: LLMResolvedRoute, secret: Optional[ResolvedSecret] +) -> Dict[str, str]: + key = secret and _secret_key(secret) + if not key: + raise LLMUpstreamError( + provider_key=route.provider_key, + status_code=None, + detail="azure endpoint has no secret", + ) + return {"api-key": key} + + +async def _bedrock_auth( + route: LLMResolvedRoute, secret: Optional[ResolvedSecret] +) -> Dict[str, str]: + # bedrock-mantle takes a Bedrock API key as a bearer token (OD16) — no SigV4 signing + # for the deployment this package wires. + token = (secret and _secret_extras(secret).get("aws_bearer_token_bedrock")) or ( + secret and _secret_key(secret) + ) + if not token: + raise LLMUpstreamError( + provider_key=route.provider_key, + status_code=None, + detail="bedrock endpoint has no bearer key", + ) + return {"Authorization": f"Bearer {token}"} + + +async def _vertex_auth( + route: LLMResolvedRoute, secret: Optional[ResolvedSecret] +) -> Dict[str, str]: + # Token minting, not signing over the body — allowed by D34 the same way SigV4 is; the + # library call never touches request/response bytes. Reuses litellm's own Vertex + # credential helper rather than its completion transformation. + from litellm.llms.vertex_ai.vertex_llm_base import VertexBase + + extras = _secret_extras(secret) if secret else {} + credentials = extras.get("vertex_ai_credentials") + project = (route.extras or {}).get("vertex_project") + if not credentials or not project: + raise LLMUpstreamError( + provider_key=route.provider_key, + status_code=None, + detail="vertex endpoint needs a service-account credential and extras.vertex_project", + ) + token, _project = await VertexBase().get_access_token_async( + credentials=credentials, project_id=project + ) + return {"Authorization": f"Bearer {token}"} + + +async def _sagemaker_auth( + route: LLMResolvedRoute, secret: Optional[ResolvedSecret] +) -> Dict[str, str]: # noqa: ARG001 + raise LLMUpstreamError( + provider_key=route.provider_key, + status_code=None, + detail="sagemaker has no fixed request protocol (OD16); not reachable through the gateway", + ) + + +_AUTH: Dict[ + LLMDeploymentKind, + Callable[ + [LLMResolvedRoute, Optional[ResolvedSecret]], + Coroutine[None, None, Dict[str, str]], + ], +] = { + LLMDeploymentKind.DIRECT: _direct_auth, + LLMDeploymentKind.CUSTOM: _custom_auth, + LLMDeploymentKind.AZURE: _azure_auth, + LLMDeploymentKind.BEDROCK: _bedrock_auth, + LLMDeploymentKind.VERTEX: _vertex_auth, + LLMDeploymentKind.SAGEMAKER: _sagemaker_auth, +} + + +async def build_auth_headers( + route: LLMResolvedRoute, secret: Optional[ResolvedSecret] +) -> Dict[str, str]: + strategy = _AUTH.get(route.deployment_kind) + if strategy is None: + raise LLMUpstreamError( + provider_key=route.provider_key, + status_code=None, + detail=f"no auth strategy for deployment_kind {route.deployment_kind!r}", + ) + return await strategy(route, secret) diff --git a/api/oss/src/core/gateways/llms/providers/passthrough/routing.py b/api/oss/src/core/gateways/llms/providers/passthrough/routing.py new file mode 100644 index 0000000000..af44800da7 --- /dev/null +++ b/api/oss/src/core/gateways/llms/providers/passthrough/routing.py @@ -0,0 +1,183 @@ +"""Routing strategies (D34, OD16): compose a URL from route fields, never from the body. + +One function per `deployment_kind`. Each raises `LLMUpstreamError` rather than guess when a +route lacks what it needs — the failure names the provider so the caller can fix the +endpoint, and it happens before any I/O. +""" + +from typing import Callable, Dict + +from oss.src.core.gateways.llms.dtos import ( + LLMDeploymentKind, + LLMProtocol, + LLMResolvedRoute, +) +from oss.src.core.gateways.llms.types import LLMUpstreamError + +# Each provider's own version segment lives in its base_url, matching the mock upstream's +# mount (`providers/mock/app.py`: `/v1/chat/completions`, `/v1/responses`, `/v1/messages`, +# all root-relative) — every base_url below already ends where its protocol path begins, so +# `MESSAGES` is "/messages", not "/v1/messages": Anthropic's own base_url carries the `/v1`. +_PROTOCOL_PATHS: Dict[LLMProtocol, str] = { + LLMProtocol.CHAT_COMPLETIONS: "/chat/completions", + LLMProtocol.RESPONSES: "/responses", + LLMProtocol.MESSAGES: "/messages", +} + +# provider_key -> fixed base URL for a DIRECT deployment whose wire needs no per-row +# configuration (open-designs.md OD16). Every entry but `anthropic` is an OpenAI-compatible +# endpoint the provider publishes itself; `anthropic` is its own native Messages wire, which +# is exactly what the Messages front door relays. +DIRECT_BASE_URLS: Dict[str, str] = { + "openai": "https://api.openai.com/v1", + "groq": "https://api.groq.com/openai/v1", + "together_ai": "https://api.together.xyz/v1", + "openrouter": "https://openrouter.ai/api/v1", + "mistral": "https://api.mistral.ai/v1", + "mistralai": "https://api.mistral.ai/v1", + "deepinfra": "https://api.deepinfra.com/v1/openai", + "perplexityai": "https://api.perplexity.ai", + "minimax": "https://api.minimax.io/v1", + "gemini": "https://generativelanguage.googleapis.com/v1beta/openai", + "cohere": "https://api.cohere.ai/compatibility/v1", + "anthropic": "https://api.anthropic.com/v1", +} + + +def _no_route(*, provider_key: str, detail: str) -> LLMUpstreamError: + return LLMUpstreamError(provider_key=provider_key, status_code=None, detail=detail) + + +def _direct_url(route: LLMResolvedRoute, protocol: LLMProtocol) -> str: + base_url = route.base_url or DIRECT_BASE_URLS.get(route.provider_key) + if not base_url: + raise _no_route( + provider_key=route.provider_key, + detail=f"provider {route.provider_key!r} has no known route (OD16 did not clear it)", + ) + return base_url.rstrip("/") + _PROTOCOL_PATHS[protocol] + + +def _custom_url(route: LLMResolvedRoute, protocol: LLMProtocol) -> str: + if not route.base_url: + raise _no_route( + provider_key=route.provider_key, + detail="custom endpoint has no base_url", + ) + return route.base_url.rstrip("/") + _PROTOCOL_PATHS[protocol] + + +def _azure_url(route: LLMResolvedRoute, protocol: LLMProtocol) -> str: + if not route.base_url: + raise _no_route( + provider_key=route.provider_key, detail="azure endpoint has no base_url" + ) + # `route.model` is the deployment name (entities.md §2.4's Azure example carries no + # separate deployment field). + url = ( + f"{route.base_url.rstrip('/')}/openai/deployments/{route.model}" + f"{_PROTOCOL_PATHS[protocol]}" + ) + if route.api_version: + url += f"?api-version={route.api_version}" + return url + + +# OD19: bedrock-mantle serves all three doors on one host, so `base_url` on a BEDROCK row is +# the host alone — each door's tail is looked up here, not in the generic _PROTOCOL_PATHS +# table (which assumes base_url already ends where the tail begins). +_BEDROCK_PROTOCOL_PATHS: Dict[LLMProtocol, str] = { + LLMProtocol.CHAT_COMPLETIONS: "/v1/chat/completions", + LLMProtocol.RESPONSES: "/v1/responses", + LLMProtocol.MESSAGES: "/anthropic/v1/messages", +} + + +def _bedrock_url(route: LLMResolvedRoute, protocol: LLMProtocol) -> str: + base_url = route.base_url or ( + f"https://bedrock-mantle.{route.region}.api.aws" if route.region else None + ) + if not base_url: + raise _no_route( + provider_key=route.provider_key, + detail="bedrock endpoint has no region or base_url", + ) + return base_url.rstrip("/") + _BEDROCK_PROTOCOL_PATHS[protocol] + + +# OD19: base_url on a VERTEX row is the host plus the shared +# /v1/projects/{project}/locations/{region} prefix — common to both Vertex doors, which then +# append only their own tail (/endpoints/openapi/... here, /publishers/anthropic/... below). +def _vertex_base_prefix(route: LLMResolvedRoute) -> str: + if route.base_url: + return route.base_url.rstrip("/") + project = (route.extras or {}).get("vertex_project") + if not (route.region and project): + raise _no_route( + provider_key=route.provider_key, + detail="vertex endpoint needs region and extras.vertex_project (or a base_url)", + ) + return ( + f"https://{route.region}-aiplatform.googleapis.com/v1/projects/{project}" + f"/locations/{route.region}" + ) + + +def _vertex_url(route: LLMResolvedRoute, protocol: LLMProtocol) -> str: + prefix = _vertex_base_prefix(route) + return f"{prefix}/endpoints/openapi{_PROTOCOL_PATHS[protocol]}" + + +def _sagemaker_url(route: LLMResolvedRoute, protocol: LLMProtocol) -> str: # noqa: ARG001 + # OD16: SageMaker has no platform-level request schema — InvokeEndpoint forwards + # opaque bytes to whatever container the customer deployed. Unreachable, deliberately. + raise _no_route( + provider_key=route.provider_key, + detail="sagemaker has no fixed request protocol (OD16); not reachable through the gateway", + ) + + +_ROUTING: Dict[LLMDeploymentKind, Callable[[LLMResolvedRoute, LLMProtocol], str]] = { + LLMDeploymentKind.DIRECT: _direct_url, + LLMDeploymentKind.CUSTOM: _custom_url, + LLMDeploymentKind.AZURE: _azure_url, + LLMDeploymentKind.BEDROCK: _bedrock_url, + LLMDeploymentKind.VERTEX: _vertex_url, + LLMDeploymentKind.SAGEMAKER: _sagemaker_url, +} + + +# D40/OD19: Vertex's Messages door reaches the real resold-Anthropic operation +# (rawPredict) — the OpenAI-compatible `_vertex_url` above composes a different wire and +# stays exactly as it is. Bedrock needs no equivalent: `_bedrock_url` above already covers +# its Messages door (bedrock-mantle takes the model in the body, not the URL). +def _vertex_messages_url(route: LLMResolvedRoute, *, stream: bool) -> str: + if not route.model: + raise _no_route( + provider_key=route.provider_key, + detail="vertex messages endpoint has no model", + ) + prefix = _vertex_base_prefix(route) + action = "streamRawPredict" if stream else "rawPredict" + return f"{prefix}/publishers/anthropic/models/{route.model}:{action}" + + +_MESSAGES_ROUTING: Dict[LLMDeploymentKind, Callable[[LLMResolvedRoute, bool], str]] = { + LLMDeploymentKind.VERTEX: _vertex_messages_url, +} + + +def build_url( + route: LLMResolvedRoute, protocol: LLMProtocol, *, stream: bool = False +) -> str: + if protocol == LLMProtocol.MESSAGES: + messages_strategy = _MESSAGES_ROUTING.get(route.deployment_kind) + if messages_strategy is not None: + return messages_strategy(route, stream=stream) + strategy = _ROUTING.get(route.deployment_kind) + if strategy is None: + raise _no_route( + provider_key=route.provider_key, + detail=f"no routing strategy for deployment_kind {route.deployment_kind!r}", + ) + return strategy(route, protocol) diff --git a/api/oss/src/core/gateways/llms/providers/passthrough/static_fields.py b/api/oss/src/core/gateways/llms/providers/passthrough/static_fields.py new file mode 100644 index 0000000000..417592fd62 --- /dev/null +++ b/api/oss/src/core/gateways/llms/providers/passthrough/static_fields.py @@ -0,0 +1,61 @@ +"""Static field rewrite for the one resold Anthropic wire that still needs it (D40, amends +D34; OD19). + +Vertex's `rawPredict` resells the Anthropic Messages wire with one fixed structural +difference: `anthropic_version` must be in the body, `model` must not (it rides the URL). +Bedrock needed the same shape when its Messages door was `InvokeModel`; it no longer does — +the door moved to `bedrock-mantle`, which takes the native Anthropic body untouched (OD19). +The table below is literal — fixed keys, fixed constant values, nothing computed from the +request — and `apply_static_fields` never branches on what those keys or values mean, only on +`deployment_kind` and `protocol`. +""" + +import json +from typing import Any, Dict, List + +from pydantic import BaseModel + +from oss.src.core.gateways.llms.dtos import LLMDeploymentKind, LLMProtocol + + +class LLMStaticFieldRewrite(BaseModel): + fields_added: Dict[str, Any] = {} + fields_removed: List[str] = [] + + +# D40: one literal entry. Both lists are constants — nothing here is derived from a +# request's content, size, or another field's value. Bedrock had an entry here until OD19 +# moved its Messages door to bedrock-mantle, which needs no rewrite at all. +STATIC_FIELD_REWRITES: Dict[LLMDeploymentKind, LLMStaticFieldRewrite] = { + LLMDeploymentKind.VERTEX: LLMStaticFieldRewrite( + fields_added={"anthropic_version": "vertex-2023-10-16"}, + fields_removed=["model"], + ), +} + + +def apply_static_fields( + *, deployment_kind: LLMDeploymentKind, protocol: LLMProtocol, body: bytes +) -> bytes: + """Applies the deployment's table entry, if any, on the Messages front door only. + + `fields_added` uses setdefault semantics (mirrors the vendor SDKs): a field the caller + already sent is left alone, never overwritten. A body this can't parse as a JSON object + is returned unchanged — that is a policy-parse failure elsewhere, not this function's job. + """ + if protocol != LLMProtocol.MESSAGES: + return body + rewrite = STATIC_FIELD_REWRITES.get(deployment_kind) + if rewrite is None: + return body + try: + payload = json.loads(body) + except (json.JSONDecodeError, TypeError): + return body + if not isinstance(payload, dict): + return body + for key in rewrite.fields_removed: + payload.pop(key, None) + for key, value in rewrite.fields_added.items(): + payload.setdefault(key, value) + return json.dumps(payload).encode() diff --git a/api/oss/src/core/gateways/llms/registry.py b/api/oss/src/core/gateways/llms/registry.py new file mode 100644 index 0000000000..9f686ee9eb --- /dev/null +++ b/api/oss/src/core/gateways/llms/registry.py @@ -0,0 +1,45 @@ +"""`LLMUpstreamRegistry` and `select_upstream` (entities.md §7.1, §8). + +`select_upstream` is pure — no DAO, no vault, no I/O — so which adapter key a deployment +resolves to is a table reviewable and testable on its own, independent of any adapter's +construction. D34 forbids body conversion, so there is exactly one relay adapter for every +deployment kind; the `passthrough`/`translated` split it replaces is gone (open-designs.md +OD16). +""" + +from typing import Dict, List, Optional + +from oss.src.core.gateways.llms.dtos import LLMDeploymentKind +from oss.src.core.gateways.llms.interfaces import LLMUpstreamInterface +from oss.src.core.gateways.llms.types import LLMAdapterNotFoundError + + +def select_upstream( + provider_key: Optional[str], deployment_kind: LLMDeploymentKind +) -> str: # noqa: ARG001 + """Picks the adapter key for the registry. Pure: no I/O, no DAO, no vault. + + `provider_key` decides nothing here any more (D34 removed the one branch that read it, + entities.md §2.4) — kept as a parameter so callers do not need to change, but the whole + decision is now `deployment_kind` alone: `MOCK` selects the test double, everything else + is the one relay. A deployment kind with no routing/auth strategy (`sagemaker`, OD16) + still resolves to `"relay"` here and fails at relay time, naming the reason — the + registry only ever misses on a typo in this table, never on a provider fact. + """ + if deployment_kind == LLMDeploymentKind.MOCK: + return "mock" + return "relay" + + +class LLMUpstreamRegistry: + def __init__(self, *, adapters: Dict[str, LLMUpstreamInterface]) -> None: + self._adapters = adapters + + def get(self, key: str) -> LLMUpstreamInterface: + adapter = self._adapters.get(key) + if adapter is None: + raise LLMAdapterNotFoundError(key=key) + return adapter + + def keys(self) -> List[str]: + return list(self._adapters.keys()) diff --git a/api/oss/src/core/gateways/llms/service.py b/api/oss/src/core/gateways/llms/service.py new file mode 100644 index 0000000000..77e717fd1d --- /dev/null +++ b/api/oss/src/core/gateways/llms/service.py @@ -0,0 +1,474 @@ +"""`LLMGatewayService` (entities.md §8): management CRUD, the generated-endpoint merge, and +the relay path's policy/allowlist/ceiling/secret/adapter-selection pipeline. +""" + +import asyncio +import json +from dataclasses import dataclass +from typing import AsyncIterator, Dict, List, Optional +from uuid import UUID + +from oss.src.core.access.permissions.types import Permission +from oss.src.core.gateways.dtos import GatewayEndpointNamespace +from oss.src.core.gateways.llms.catalog import ( + standard_llm_endpoint, + standard_llm_endpoints, +) +from oss.src.core.gateways.llms.dtos import ( + LLMCallContext, + LLMDeploymentKind, + LLMEndpoint, + LLMEndpointSettings, + LLMModelFilter, + LLMEndpointCreate, + LLMEndpointEdit, + LLMEndpointQuery, + LLMEndpointRoute, + LLMProtocol, + LLMResolvedRoute, +) +from oss.src.core.gateways.llms.interfaces import ( + LLMEndpointsDAOInterface, + LLMRelayResult, +) +from oss.src.core.gateways.llms.registry import LLMUpstreamRegistry, select_upstream +from oss.src.core.gateways.llms.types import ( + LLMEndpointNotFoundError, + LLMModelNotAllowedError, + LLMUpstreamError, +) +from oss.src.core.gateways.policy.dtos import ( + BoundSecretRef, + SecretMode, + SecretRef, + GatewayOutcome, + GatewayPlane, + GatewayTarget, + PolicyDecision, + ProviderKeyRef, + ResolvedSecret, +) +from oss.src.core.gateways.policy.interfaces import SecretsResolverInterface +from oss.src.core.gateways.policy.service import GatewayPolicyService +from oss.src.core.gateways.policy.types import CeilingExceededError, PolicyDeniedError +from oss.src.core.gateways.types import GatewayEndpointInactiveError +from oss.src.core.shared.dtos import Windowing +from oss.src.utils.context import AuthScope + + +@dataclass +class _ResolvedLlmTarget: + """The generated endpoint or the row, plus which namespace answered — service-internal, + never crosses a layer (entities.md §8).""" + + namespace: GatewayEndpointNamespace + name: str + provider_key: Optional[str] + deployment_kind: LLMDeploymentKind + models: LLMModelFilter + route_data: LLMEndpointRoute + settings: LLMEndpointSettings + endpoint_id: Optional[UUID] = None + secret_id: Optional[UUID] = None + is_active: bool = True + + def target_path(self) -> str: + return f"{self.namespace.value}/{self.name}" + + def as_policy_target(self, *, model: Optional[str] = None) -> GatewayTarget: + return GatewayTarget( + plane=GatewayPlane.LLM, + namespace=self.namespace, + name=self.name, + endpoint_id=self.endpoint_id, + model=model, + ) + + def secret_ref(self) -> Optional[SecretRef]: + if self.namespace == GatewayEndpointNamespace.STANDARD: + return ProviderKeyRef(provider_key=self.provider_key) + if self.secret_id is not None: + return BoundSecretRef(secret_id=self.secret_id) + # A custom row with no bound secret is a NONE-scheme target (the mocks, D23) — + # nothing to resolve. + return None + + def route(self, context: LLMCallContext) -> LLMResolvedRoute: + return LLMResolvedRoute( + provider_key=self.provider_key, + deployment_kind=self.deployment_kind, + model=context.model, + base_url=self.route_data.base_url, + api_version=self.route_data.api_version, + region=self.route_data.region, + headers=self.route_data.headers, + extras=self.route_data.extras, + settings=self.settings, + ) + + +def _parse_call_context(body: bytes, protocol: LLMProtocol) -> LLMCallContext: + """The service's own model/stream extraction — a private duplicate of WP6's + `apis/fastapi/gateways/llms/utils.py::parse_llm_call_context`, not an import of it: core + must not import the api layer (`api/AGENTS.md`'s layering rule), and this package owns + `relay_chat_completion`'s full body, which needs the same two fields internally. + `model`/`stream` share field names across every protocol (WP23); `protocol` is stamped + from the caller so the ceiling check binds to the right request field.""" + try: + payload = json.loads(body) if body else {} + except (json.JSONDecodeError, TypeError): + payload = {} + model = payload.get("model") if isinstance(payload, dict) else None + if not model: + raise ValueError("request body names no model") + return LLMCallContext( + model=model, stream=bool(payload.get("stream", False)), protocol=protocol + ) + + +# Per-protocol ceiling field name(s) (D33, D34, specs-wp23.md): Chat Completions names it +# `max_tokens` or `max_completion_tokens` on reasoning models; Responses `max_output_tokens`; +# Messages `max_tokens`. The config key stays `settings.max_output_tokens` regardless. +_CEILING_FIELDS: Dict[LLMProtocol, tuple] = { + LLMProtocol.CHAT_COMPLETIONS: ("max_tokens", "max_completion_tokens"), + LLMProtocol.RESPONSES: ("max_output_tokens",), + LLMProtocol.MESSAGES: ("max_tokens",), +} + + +def _requested_max_output_tokens(body: bytes, protocol: LLMProtocol) -> Optional[int]: + try: + payload = json.loads(body) if body else {} + except (json.JSONDecodeError, TypeError): + return None + if not isinstance(payload, dict): + return None + for key in _CEILING_FIELDS[protocol]: + value = payload.get(key) + if isinstance(value, int): + return value + return None + + +class LLMGatewayService: + def __init__( + self, + *, + llm_endpoints_dao: LLMEndpointsDAOInterface, + policy: GatewayPolicyService, + resolver: SecretsResolverInterface, + upstream_registry: LLMUpstreamRegistry, + ) -> None: + self.llm_endpoints_dao = llm_endpoints_dao + self.policy = policy + self.resolver = resolver + self.upstream_registry = upstream_registry + + # --- management: thin over the DAO, plus the generated merge ------------ # + + async def create_endpoint( + self, + *, + project_id: UUID, + user_id: UUID, + # + endpoint: LLMEndpointCreate, + ) -> Optional[LLMEndpoint]: + return await self.llm_endpoints_dao.create_endpoint( + project_id=project_id, user_id=user_id, endpoint=endpoint + ) + + async def fetch_endpoint( + self, + *, + project_id: UUID, + # + endpoint_id: UUID, + ) -> Optional[LLMEndpoint]: + return await self.llm_endpoints_dao.fetch_endpoint( + project_id=project_id, endpoint_id=endpoint_id + ) + + async def edit_endpoint( + self, + *, + project_id: UUID, + user_id: UUID, + # + endpoint: LLMEndpointEdit, + ) -> Optional[LLMEndpoint]: + return await self.llm_endpoints_dao.edit_endpoint( + project_id=project_id, user_id=user_id, endpoint=endpoint + ) + + async def delete_endpoint( + self, + *, + project_id: UUID, + # + endpoint_id: UUID, + ) -> bool: + return await self.llm_endpoints_dao.delete_endpoint( + project_id=project_id, endpoint_id=endpoint_id + ) + + async def query_endpoints( + self, + *, + project_id: UUID, + # + endpoint: Optional[LLMEndpointQuery] = None, + # + windowing: Optional[Windowing] = None, + ) -> List[LLMEndpoint]: + return await self.llm_endpoints_dao.query_endpoints( + project_id=project_id, endpoint=endpoint, windowing=windowing + ) + + async def list_endpoints(self, *, scope: AuthScope) -> List[LLMEndpoint]: + """The merge (D20): generated standard endpoints, existing iff a provider_key secret + exists for the provider, plus every custom row. The only read that spans namespaces. + + Takes the scope rather than a bare project_id (R14): existence is a per-owner fact + the moment user-owned secrets ship, and fabricating an AuthScope to satisfy the port + put a nil UUID where a user identity goes.""" + project_id = scope.project_id + provider_keys = await self.resolver.available_provider_keys(scope=scope) + + generated = [ + endpoint + for endpoint in standard_llm_endpoints() + if endpoint.provider_key in provider_keys + ] + custom = await self.llm_endpoints_dao.query_endpoints(project_id=project_id) + return generated + custom + + # --- the data plane (WP6, WP7) ------------------------------------------ # + + async def list_models( + self, + *, + scope: AuthScope, + namespace: GatewayEndpointNamespace, + name: str, + ) -> List[str]: + """Backs `GET /v1/models` (R3): the allowlist itself, per endpoint. No secret + resolved, no upstream called.""" + target = await self._resolve_target( + project_id=scope.project_id, namespace=namespace, name=name + ) + self._check_active(target=target) + + decision = await self.policy.authorize( + scope=scope, + permission=Permission.USE_LLM_ENDPOINTS, + target=target.as_policy_target(), + ) + if not decision.allowed: + await self.policy.record( + scope=scope, + target=target.as_policy_target(), + decision=decision, + outcome=GatewayOutcome(status_code=403), + ) + raise PolicyDeniedError( + permission=Permission.USE_LLM_ENDPOINTS, target=target.target_path() + ) + + return target.models.enumerate() + + async def relay_chat_completion( + self, + *, + scope: AuthScope, + namespace: GatewayEndpointNamespace, + name: str, + # + body: bytes, + headers: Dict[str, str], + protocol: LLMProtocol = LLMProtocol.CHAT_COMPLETIONS, + ) -> LLMRelayResult: + """One method for every front door (D33, WP23): a door supplies its own + `protocol` from its own minimal parse; everything below stays blind to which + one it was, except the ceiling field name.""" + target = await self._resolve_target( + project_id=scope.project_id, namespace=namespace, name=name + ) + self._check_active(target=target) + context = _parse_call_context(body, protocol) + + # Allowlist and ceiling before secret (§8): a refused model must not cost a + # vault read, and the refusal reason must be the allowlist, never a coincidental + # secret gap. + self._check_allowlist(target=target, context=context) + self._check_ceilings(target=target, context=context, body=body) + + policy_target = target.as_policy_target(model=context.model) + decision = await self.policy.authorize( + scope=scope, + permission=Permission.USE_LLM_ENDPOINTS, + target=policy_target, + ) + if not decision.allowed: + # Denial recorded before the exception leaves — an audit trail that only + # records successes answers "did every call get checked" wrongly. + await self.policy.record( + scope=scope, + target=policy_target, + decision=decision, + outcome=GatewayOutcome(status_code=403), + ) + raise PolicyDeniedError( + permission=Permission.USE_LLM_ENDPOINTS, target=target.target_path() + ) + + ref = target.secret_ref() + secret = ( + await self.resolver.resolve( + scope=scope, ref=ref, mode=SecretMode.PROJECT_ONLY + ) + if ref is not None + else None + ) + + adapter = self.upstream_registry.get( + select_upstream(target.provider_key, target.deployment_kind) + ) + # Enforced here, not per adapter: `timeout_seconds` is a property of the + # endpoint, and an adapter that forgets it would otherwise have no ceiling at + # all. Streaming bounds time-to-first-byte — the proxy drains the body after + # this returns, and a long legitimate stream is not a timeout. + try: + result = await asyncio.wait_for( + adapter.relay_chat_completion( + route=target.route(context), + secret=secret, + # + context=context, + body=body, + headers=headers, + ), + timeout=target.settings.timeout_seconds, + ) + except asyncio.TimeoutError as e: + raise LLMUpstreamError( + provider_key=target.provider_key, + status_code=None, + detail="upstream timed out", + ) from e + + # Both paths record after the drain, never before: every adapter fills + # `result.usage` while its body generator runs, and the proxy is what advances + # it — reading usage here would record None on every call (§8). + result.body = self._drain_and_record( + body=result.body, + scope=scope, + target=policy_target, + decision=decision, + result=result, + secret=secret, + ) + return result + + # --- internals ------------------------------------------------------------ # + + async def _resolve_target( + self, *, project_id: UUID, namespace: GatewayEndpointNamespace, name: str + ) -> _ResolvedLlmTarget: + if namespace == GatewayEndpointNamespace.STANDARD: + endpoint = standard_llm_endpoint(provider_key=name) + if endpoint is None: + raise LLMEndpointNotFoundError(namespace=namespace, name=name) + return _ResolvedLlmTarget( + namespace=GatewayEndpointNamespace.STANDARD, + name=name, + provider_key=endpoint.provider_key, + deployment_kind=endpoint.deployment_kind, + models=endpoint.data.models, + route_data=endpoint.data.route, + settings=endpoint.data.settings, + ) + + if namespace == GatewayEndpointNamespace.CUSTOM: + row = await self.llm_endpoints_dao.fetch_endpoint_by_slug( + project_id=project_id, slug=name + ) + if row is None: + raise LLMEndpointNotFoundError(namespace=namespace, name=name) + return _ResolvedLlmTarget( + namespace=GatewayEndpointNamespace.CUSTOM, + name=row.slug or name, + provider_key=row.provider_key, + deployment_kind=row.deployment_kind, + models=row.data.models, + route_data=row.data.route, + settings=row.data.settings, + endpoint_id=row.id, + secret_id=row.secret_id, + is_active=row.flags.is_active, + ) + + # BUILTIN: reserved, empty on the LLM plane until we supply the key (D30). + raise LLMEndpointNotFoundError(namespace=namespace, name=name) + + @staticmethod + def _check_active(*, target: _ResolvedLlmTarget) -> None: + if not target.is_active: + raise GatewayEndpointInactiveError(target=target.target_path()) + + def _check_allowlist( + self, *, target: _ResolvedLlmTarget, context: LLMCallContext + ) -> None: + if not target.models.allows(context.model): + raise LLMModelNotAllowedError( + model=context.model, namespace=target.namespace, name=target.name + ) + + def _check_ceilings( + self, *, target: _ResolvedLlmTarget, context: LLMCallContext, body: bytes + ) -> None: + ceiling = target.settings.max_output_tokens + if ceiling is None: + return + requested = _requested_max_output_tokens(body, context.protocol) + if requested is None or requested <= ceiling: + return + raise CeilingExceededError( + ceiling="max_output_tokens", + requested=requested, + allowed=ceiling, + target=target.target_path(), + ) + + def _outcome_from( + self, *, result: LLMRelayResult, secret: Optional[ResolvedSecret] + ) -> GatewayOutcome: + return GatewayOutcome( + status_code=result.status_code, + usage=result.usage, + owner=secret.owner if secret is not None else None, + origin=secret.origin if secret is not None else None, + ) + + async def _drain_and_record( + self, + *, + body: AsyncIterator[bytes], + scope: AuthScope, + target: GatewayTarget, + decision: PolicyDecision, + result: LLMRelayResult, + secret: Optional[ResolvedSecret], + ) -> AsyncIterator[bytes]: + try: + async for chunk in body: + yield chunk + finally: + # Fires on natural exhaustion and on a mid-stream break alike — usage is + # whatever the adapter had populated by then, None if the crash pre-dated it. + await self.policy.record( + scope=scope, + target=target, + decision=decision, + outcome=self._outcome_from(result=result, secret=secret), + ) diff --git a/api/oss/src/core/gateways/llms/types.py b/api/oss/src/core/gateways/llms/types.py new file mode 100644 index 0000000000..a22b56d37d --- /dev/null +++ b/api/oss/src/core/gateways/llms/types.py @@ -0,0 +1,51 @@ +"""LLM plane domain exceptions (entities.md §5).""" + +from typing import Optional + +from oss.src.core.gateways.dtos import GatewayEndpointNamespace +from oss.src.core.gateways.types import GatewaysError + + +class LLMEndpointNotFoundError(GatewaysError): + def __init__(self, *, namespace: GatewayEndpointNamespace, name: str): + self.namespace = namespace + self.name = name + super().__init__(f"LLM endpoint not found: {namespace.value}/{name}") + + +class LLMModelNotAllowedError(GatewaysError): + """The model is outside the endpoint's allowlist — a custom endpoint's + declared model allowlist, or a standard provider's catalogue (§4.3).""" + + def __init__(self, *, model: str, namespace: GatewayEndpointNamespace, name: str): + self.model = model + self.namespace = namespace + self.name = name + super().__init__(f"Model {model} not allowed on {namespace.value}/{name}") + + +class LLMAdapterNotFoundError(GatewaysError): + """No south-port adapter registered under this key (registry.py's own miss, + entities.md §7.1's ``ConnectionsGatewayRegistry``/``ProviderNotFoundError`` shape, + copied into this domain's vocabulary rather than importing the integrations one).""" + + def __init__(self, *, key: str): + self.key = key + super().__init__(f"No LLM upstream adapter registered under {key!r}") + + +class LLMUpstreamError(GatewaysError): + """The upstream failed after policy allowed. Carries the upstream status so + the proxy can relay a faithful OpenAI-shaped error (§9).""" + + def __init__( + self, + *, + provider_key: Optional[str], + status_code: Optional[int] = None, + detail: Optional[str] = None, + ): + self.provider_key = provider_key + self.status_code = status_code + self.detail = detail + super().__init__(f"Upstream {provider_key} failed ({status_code})") diff --git a/api/oss/src/core/gateways/mcps/__init__.py b/api/oss/src/core/gateways/mcps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/core/gateways/mcps/dtos.py b/api/oss/src/core/gateways/mcps/dtos.py new file mode 100644 index 0000000000..28c81bff1c --- /dev/null +++ b/api/oss/src/core/gateways/mcps/dtos.py @@ -0,0 +1,148 @@ +"""The MCP plane's DTOs (entities.md §4.4).""" + +from typing import Dict, List, Optional, Union +from uuid import UUID + +from pydantic import BaseModel, Field + +from oss.src.core.gateway.connections.dtos import Connection +from oss.src.core.gateways.dtos import ( + GatewayAuthScheme, + GatewayEndpointFilter, + GatewayEndpointNamespace, + GatewayEndpointRoute, + GatewayEndpointSettings, +) +from oss.src.core.gateways.policy.dtos import ResolvedSecret +from oss.src.core.shared.dtos import ( + Header, + Identifier, + Lifecycle, + Metadata, + Slug, + Status, +) + + +# The MCP plane's name for the shared scheme enum. Same members, same storage — the +# plane reads its own vocabulary rather than reaching for the domain root's. +MCPAuthScheme = GatewayAuthScheme + + +# The providers inside the `builtin` namespace (D30). Agenta is one supplier among +# them, not a namespace of its own. +AGENTA_PROVIDER = "agenta" +COMPOSIO_PROVIDER = "composio" + + +class MCPEndpointRoute(GatewayEndpointRoute): + """Nothing beyond the shared pair: an MCP server is one URL (D16) and the + protocol POSTs to it directly — unlike the LLM planes's `base_url`, no path is + appended. The subclass exists so a first MCP-only route field is a DTO change.""" + + +# The MCP plane's name for the shared filter. Same shape, same storage. +MCPToolFilter = GatewayEndpointFilter + + +class MCPEndpointSettings(GatewayEndpointSettings): + """Nothing beyond the shared field yet; the subclass exists so a first + MCP-only knob is a DTO change, symmetric with the LLM side.""" + + +class MCPOAuthData(BaseModel): + """Discovered authorization facts, cached on the row. Written by the OAuth + checkpoint (WP17); absent until then. Not secret material — discovery + metadata only (D3 holds: tokens live in the vault).""" + + resource: Optional[str] = None + authorization_server: Optional[str] = None + scopes_offered: List[str] = Field(default_factory=list) + + +class MCPEndpointData(BaseModel): + route: MCPEndpointRoute = Field(default_factory=MCPEndpointRoute) + tools: MCPToolFilter = Field(default_factory=MCPToolFilter) + settings: MCPEndpointSettings = Field(default_factory=MCPEndpointSettings) + oauth: Optional[MCPOAuthData] = None + + +class MCPEndpointFlags(BaseModel): + is_active: bool = True + is_valid: bool = True # server-set: a failed refresh flips it (§2.6, D18) + + +class MCPEndpoint(Identifier, Slug, Header, Lifecycle, Metadata): + auth_mode: MCPAuthScheme + namespace: GatewayEndpointNamespace = GatewayEndpointNamespace.CUSTOM + secret_id: Optional[UUID] = None + connection_id: Optional[UUID] = ( + None # BUILTIN only: the brokered gateway_connections row (§1) + ) + provider_key: Optional[str] = None + integration_key: Optional[str] = ( + None # BUILTIN only, with slug: the three URL segments (§2.3) + ) + # + data: MCPEndpointData + flags: MCPEndpointFlags = Field(default_factory=MCPEndpointFlags) + status: Optional[Status] = None + + +class MCPEndpointCreate(Slug, Header, Metadata): + auth_mode: MCPAuthScheme + secret_id: Optional[UUID] = None + # + data: MCPEndpointData + flags: MCPEndpointFlags = Field(default_factory=MCPEndpointFlags) + + +class MCPEndpointEdit(Identifier, Header, Metadata): + auth_mode: MCPAuthScheme # editable: none -> oauth; service revalidates secret_id + secret_id: Optional[UUID] = None + # + data: MCPEndpointData + flags: MCPEndpointFlags = Field(default_factory=MCPEndpointFlags) + + +class MCPEndpointQuery(BaseModel): + auth_mode: Optional[MCPAuthScheme] = None + slug: Optional[str] = None + + +class MCPCallContext(BaseModel): + """What routing reads from the protocol's method and target headers — the + body is never parsed for routing (`mcp.md`, header-based routing). The + exact header names are pinned against the 2026-07-28 revision at + implementation time, in apis/fastapi/gateways/mcps/utils.py.""" + + method: str + target: Optional[str] = None + + +class MCPResolvedRoute(BaseModel): + url: str + headers: Dict[str, str] = Field(default_factory=dict) + settings: MCPEndpointSettings = Field(default_factory=MCPEndpointSettings) + + +# --- the two secret mechanisms, made legible (D27) ------------------------ # + + +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` is + that domain's own DTO (core/gateway/connections/dtos.py), imported by + reference (§1) — no copy, no subclass.""" + + connection: Connection + + +MCPRelayAuth = Union[MCPDirectAuth, MCPBrokeredAuth] diff --git a/api/oss/src/core/gateways/mcps/interfaces.py b/api/oss/src/core/gateways/mcps/interfaces.py new file mode 100644 index 0000000000..8ced62e65a --- /dev/null +++ b/api/oss/src/core/gateways/mcps/interfaces.py @@ -0,0 +1,139 @@ +"""MCP plane DAO interfaces and south port (entities.md §7, §7.1). + +The registry lives in `registry.py`, per §0's file layout. §7.1 shows it in the same code +block as the port, which is presentation, not placement (R13). + +DAOs open their own sessions; services never touch the engine. `project_id` is first on +every method (tenant scope is structural); `user_id` on writes only. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Dict, List, Optional +from uuid import UUID + +from oss.src.core.gateways.mcps.dtos import ( + MCPCallContext, + MCPEndpoint, + MCPEndpointCreate, + MCPEndpointEdit, + MCPEndpointQuery, + MCPRelayAuth, + MCPResolvedRoute, +) +from oss.src.core.shared.dtos import Windowing + + +class MCPEndpointsDAOInterface(ABC): + """Same six verbs, same semantics, over mcps_endpoints.""" + + @abstractmethod + async def create_endpoint( + self, + *, + project_id: UUID, + user_id: UUID, + # + endpoint: MCPEndpointCreate, + ) -> Optional[MCPEndpoint]: + raise NotImplementedError + + @abstractmethod + async def fetch_endpoint( + self, + *, + project_id: UUID, + # + endpoint_id: UUID, + ) -> Optional[MCPEndpoint]: + raise NotImplementedError + + @abstractmethod + async def fetch_endpoint_by_slug( + self, + *, + project_id: UUID, + # + slug: str, + ) -> Optional[MCPEndpoint]: + raise NotImplementedError + + @abstractmethod + async def edit_endpoint( + self, + *, + project_id: UUID, + user_id: UUID, + # + endpoint: MCPEndpointEdit, + ) -> Optional[MCPEndpoint]: + raise NotImplementedError + + @abstractmethod + async def delete_endpoint( + self, + *, + project_id: UUID, + # + endpoint_id: UUID, + ) -> bool: + raise NotImplementedError + + @abstractmethod + async def query_endpoints( + self, + *, + project_id: UUID, + # + endpoint: Optional[MCPEndpointQuery] = None, + # + windowing: Optional[Windowing] = None, + ) -> List[MCPEndpoint]: + raise NotImplementedError + + +# --- the south port ---------------------------------------------------------- # + + +@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). + + A `custom` route's URL was typed by a user and the adapter is what + connects to it, so the outbound guard runs here before the POST: the + resolving variant in core/webhooks/utils.py, connecting to the literal + IP it returns rather than re-resolving the hostname (D28). A blocked + target is MCPUpstreamError — a transport refusal, never relayed as an + upstream body. Only `custom` needs it: agenta targets are ours and + builtin targets are the broker's.""" + raise NotImplementedError diff --git a/api/oss/src/core/gateways/mcps/oauth/__init__.py b/api/oss/src/core/gateways/mcps/oauth/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/core/gateways/mcps/oauth/client.py b/api/oss/src/core/gateways/mcps/oauth/client.py new file mode 100644 index 0000000000..5195b97236 --- /dev/null +++ b/api/oss/src/core/gateways/mcps/oauth/client.py @@ -0,0 +1,284 @@ +"""`MCPOAuthClient` (specs-wp17.md "Why not OAuthClientProvider"). + +Discovery, RFC 7591 dynamic registration and authorization-code token exchange, built +from the SDK's own wire DTOs (`mcp.shared.auth`) and PKCE generator +(`mcp.client.auth.oauth2.PKCEParameters`) rather than from `OAuthClientProvider`, whose +`async_auth_flow` blocks one coroutine across the whole flow — a shape a web deployment's +two-separate-HTTP-requests callback cannot satisfy (D26). +""" + +import re +from typing import Any, Dict, List, Optional +from urllib.parse import urlencode, urljoin, urlparse + +import httpx +from mcp.client.auth.oauth2 import PKCEParameters +from mcp.shared.auth import ( + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthMetadata, + OAuthToken, + ProtectedResourceMetadata, +) +from pydantic import ValidationError + +from oss.src.core.gateways.mcps.oauth.dtos import MCPOAuthDiscovery +from oss.src.core.gateways.mcps.oauth.types import ( + MCPOAuthDiscoveryError, + MCPOAuthRegistrationError, + MCPOAuthTokenExchangeError, +) + +_TIMEOUT_SECONDS = 15.0 + + +def _authorization_base_url(url: str) -> str: + parsed = urlparse(url) + return f"{parsed.scheme}://{parsed.netloc}" + + +def _protected_resource_urls( + server_url: str, *, resource_metadata_url: Optional[str] = None +) -> List[str]: + urls = [] + if resource_metadata_url: + urls.append(resource_metadata_url) + parsed = urlparse(server_url) + base = _authorization_base_url(server_url) + if parsed.path and parsed.path != "/": + urls.append( + urljoin(base, f"/.well-known/oauth-protected-resource{parsed.path}") + ) + urls.append(urljoin(base, "/.well-known/oauth-protected-resource")) + return urls + + +_RESOURCE_METADATA_RE = re.compile(r'resource_metadata=(?:"([^"]+)"|([^\s,]+))') + + +def _resource_metadata_url_from_response(response: httpx.Response) -> Optional[str]: + """RFC 9728 s3: a 401's `WWW-Authenticate` names the PRM location directly.""" + if response.status_code != 401: + return None + header = response.headers.get("WWW-Authenticate") + if not header: + return None + match = _RESOURCE_METADATA_RE.search(header) + if not match: + return None + return match.group(1) or match.group(2) + + +def _authorization_server_metadata_urls(authorization_server: str) -> List[str]: + parsed = urlparse(authorization_server) + base = _authorization_base_url(authorization_server) + urls = [] + if parsed.path and parsed.path != "/": + urls.append( + urljoin( + base, + f"/.well-known/oauth-authorization-server{parsed.path.rstrip('/')}", + ) + ) + urls.append(urljoin(base, "/.well-known/oauth-authorization-server")) + if parsed.path and parsed.path != "/": + urls.append( + urljoin(base, f"/.well-known/openid-configuration{parsed.path.rstrip('/')}") + ) + urls.append(f"{authorization_server.rstrip('/')}/.well-known/openid-configuration") + return urls + + +class MCPOAuthClient: + def __init__(self, *, transport: Optional[httpx.BaseTransport] = None) -> None: + # Injectable seam for tests, matching HttpMCPAdapter / ComposioConnectionsAdapter. + self._transport = transport + + def _client(self) -> httpx.AsyncClient: + return httpx.AsyncClient(timeout=_TIMEOUT_SECONDS, transport=self._transport) + + async def discover(self, *, server_url: str) -> MCPOAuthDiscovery: + async with self._client() as client: + prm = await self._discover_protected_resource(client, server_url=server_url) + authorization_server = str(prm.authorization_servers[0]) + metadata = await self._discover_authorization_server( + client, authorization_server=authorization_server + ) + + return MCPOAuthDiscovery( + resource=str(prm.resource), + authorization_server=authorization_server, + scopes_offered=prm.scopes_supported or metadata.scopes_supported or [], + authorization_endpoint=str(metadata.authorization_endpoint), + token_endpoint=str(metadata.token_endpoint), + registration_endpoint=( + str(metadata.registration_endpoint) + if metadata.registration_endpoint + else None + ), + ) + + async def _discover_protected_resource( + self, client: httpx.AsyncClient, *, server_url: str + ) -> ProtectedResourceMetadata: + resource_metadata_url = await self._probe_resource_metadata_url( + client, server_url=server_url + ) + for url in _protected_resource_urls( + server_url, resource_metadata_url=resource_metadata_url + ): + try: + response = await client.get(url) + except httpx.RequestError: + continue + if response.status_code != 200: + continue + try: + return ProtectedResourceMetadata.model_validate_json(response.content) + except ValidationError: + continue + raise MCPOAuthDiscoveryError( + server_url=server_url, detail="no protected-resource metadata found" + ) + + async def _probe_resource_metadata_url( + self, client: httpx.AsyncClient, *, server_url: str + ) -> Optional[str]: + try: + response = await client.get(server_url) + except httpx.RequestError: + return None + return _resource_metadata_url_from_response(response) + + async def _discover_authorization_server( + self, client: httpx.AsyncClient, *, authorization_server: str + ) -> OAuthMetadata: + for url in _authorization_server_metadata_urls(authorization_server): + try: + response = await client.get(url) + except httpx.RequestError: + continue + if response.status_code != 200: + continue + try: + return OAuthMetadata.model_validate_json(response.content) + except ValidationError: + continue + raise MCPOAuthDiscoveryError( + server_url=authorization_server, + detail="no authorization-server metadata found", + ) + + async def register( + self, + *, + authorization_server: str, + registration_endpoint: Optional[str], + redirect_uri: str, + scopes: List[str], + ) -> OAuthClientInformationFull: + endpoint = registration_endpoint or urljoin( + _authorization_base_url(authorization_server), "/register" + ) + metadata = OAuthClientMetadata( + redirect_uris=[redirect_uri], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scope=" ".join(scopes) if scopes else None, + client_name="Agenta", + ) + body = metadata.model_dump(by_alias=True, mode="json", exclude_none=True) + + async with self._client() as client: + try: + response = await client.post(endpoint, json=body) + except httpx.RequestError as e: + raise MCPOAuthRegistrationError( + authorization_server=authorization_server, detail=str(e) + ) from e + + if response.status_code not in (200, 201): + raise MCPOAuthRegistrationError( + authorization_server=authorization_server, + detail=f"{response.status_code} {response.text}", + ) + try: + return OAuthClientInformationFull.model_validate_json(response.content) + except ValidationError as e: + raise MCPOAuthRegistrationError( + authorization_server=authorization_server, detail=str(e) + ) from e + + def build_pkce(self) -> PKCEParameters: + return PKCEParameters.generate() + + def authorization_url( + self, + *, + authorization_endpoint: str, + client_id: str, + redirect_uri: str, + code_challenge: str, + state: str, + scopes: List[str], + resource: Optional[str] = None, + ) -> str: + params: Dict[str, Any] = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + } + if scopes: + params["scope"] = " ".join(scopes) + if resource: + params["resource"] = resource + return f"{authorization_endpoint}?{urlencode(params)}" + + async def exchange_token( + self, + *, + token_endpoint: str, + code: str, + code_verifier: str, + redirect_uri: str, + client_info: OAuthClientInformationFull, + resource: Optional[str] = None, + ) -> OAuthToken: + data: Dict[str, Any] = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": client_info.client_id, + "code_verifier": code_verifier, + } + if client_info.client_secret: + data["client_secret"] = client_info.client_secret + if resource: + data["resource"] = resource + + async with self._client() as client: + try: + response = await client.post( + token_endpoint, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + except httpx.RequestError as e: + raise MCPOAuthTokenExchangeError( + token_endpoint=token_endpoint, detail=str(e) + ) from e + + if response.status_code != 200: + raise MCPOAuthTokenExchangeError( + token_endpoint=token_endpoint, + detail=f"{response.status_code} {response.text}", + ) + try: + return OAuthToken.model_validate_json(response.content) + except ValidationError as e: + raise MCPOAuthTokenExchangeError( + token_endpoint=token_endpoint, detail=str(e) + ) from e diff --git a/api/oss/src/core/gateways/mcps/oauth/dtos.py b/api/oss/src/core/gateways/mcps/oauth/dtos.py new file mode 100644 index 0000000000..634e6895a9 --- /dev/null +++ b/api/oss/src/core/gateways/mcps/oauth/dtos.py @@ -0,0 +1,29 @@ +"""DTOs for the MCP OAuth client (specs-wp17.md).""" + +from typing import List, Optional +from uuid import UUID + +from pydantic import BaseModel + + +class MCPOAuthDiscovery(BaseModel): + """Feeds `MCPEndpointData.oauth` (`resource`, `authorization_server`, + `scopes_offered`) and the connect-time scope checklist. No secret involved.""" + + resource: str + authorization_server: str + scopes_offered: List[str] = [] + authorization_endpoint: str + token_endpoint: str + registration_endpoint: Optional[str] = None + + +class MCPOAuthAuthorizationStart(BaseModel): + authorization_url: str + state: str + + +class MCPOAuthCompletion(BaseModel): + project_id: UUID + server_url: str + secret_id: UUID diff --git a/api/oss/src/core/gateways/mcps/oauth/registration.py b/api/oss/src/core/gateways/mcps/oauth/registration.py new file mode 100644 index 0000000000..16e2c48f52 --- /dev/null +++ b/api/oss/src/core/gateways/mcps/oauth/registration.py @@ -0,0 +1,96 @@ +"""Client registration strategy (specs-wp20.md). + +Two mechanisms can produce the `OAuthClientInformationFull` `begin()`/`complete()` need: +the storage-backed outbound RFC 7591 path (WP17, unchanged, always safe) and the client +identity document below — a public client whose `client_id` is an HTTPS URL the +authorization server fetches (D26). Choosing between them never observes anything from +the authorization server; it runs entirely on facts about our own deployment, before the +browser is ever redirected — see specs-wp20.md "Why the choice cannot be attempt-and- +fall-back" for why that observation is structurally unavailable. +""" + +import ipaddress +import socket +from typing import Callable, List +from urllib.parse import urlparse + +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata + +_METADATA_PATH = "/gateways/mcps/oauth/client-metadata.json" + +Resolver = Callable[[str], List[str]] + + +def _default_resolve(hostname: str) -> List[str]: + return [info[4][0] for info in socket.getaddrinfo(hostname, None)] + + +def client_metadata_url(*, api_url: str) -> str: + """The client_id: the identity document's own URL, fetched by the authorization + server (never by us).""" + return f"{api_url.rstrip('/')}{_METADATA_PATH}" + + +def client_metadata_document(*, api_url: str, redirect_uri: str) -> OAuthClientMetadata: + """The static, deployment-wide JSON body served at `client_metadata_url()`. One + document for every project on this deployment — the client identity is the Agenta + application, not a tenant; per-project scoping stays on the `oauth_grant` secret, + unaffected by which client mechanism produced it.""" + return OAuthClientMetadata( + redirect_uris=[redirect_uri], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + token_endpoint_auth_method="none", + client_name="Agenta", + ) + + +def identity_document_client_info( + *, api_url: str, redirect_uri: str +) -> OAuthClientInformationFull: + """The internal client-info shape for the document strategy — deterministic, so + `complete()` can rebuild it without having stored anything (nothing was ever + registered; there is nothing to persist).""" + document = client_metadata_document(api_url=api_url, redirect_uri=redirect_uri) + return OAuthClientInformationFull( + client_id=client_metadata_url(api_url=api_url), + **document.model_dump(), + ) + + +def _is_public_ip(ip: ipaddress._BaseAddress) -> bool: + return not ( + 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 is_publicly_resolvable( + api_url: str, *, resolve: Resolver = _default_resolve +) -> bool: + """The detector (specs-wp20.md "The detector"). Conservative by construction: + every resolved address must classify public, and any ambiguity — no https scheme, + no hostname, a lookup error, an empty answer, one private address among several — + answers False. False only ever steers to the always-safe outbound path (WP17); a + wrong False costs one unnecessary RFC 7591 registration. A wrong True is the + direction that fails silently on the authorization server's side (specs-wp20.md + "Wrong in each direction"), so nothing here is allowed to guess in that direction. + """ + parsed = urlparse(api_url) + if parsed.scheme != "https" or not parsed.hostname: + return False + try: + addresses = resolve(parsed.hostname) + except Exception: + return False + if not addresses: + return False + try: + parsed_ips = [ipaddress.ip_address(a) for a in addresses] + except ValueError: + return False + return all(_is_public_ip(ip) for ip in parsed_ips) diff --git a/api/oss/src/core/gateways/mcps/oauth/service.py b/api/oss/src/core/gateways/mcps/oauth/service.py new file mode 100644 index 0000000000..a78d5fe1cd --- /dev/null +++ b/api/oss/src/core/gateways/mcps/oauth/service.py @@ -0,0 +1,193 @@ +"""`MCPOAuthConnectService` (specs-wp17.md "The two-phase connect service"). + +`begin()`/`complete()` are the only entry points WP18's two routes need. Neither touches +an `MCPEndpoint` row — `complete()` returns the written secret's id, and wiring it onto +`endpoint.secret_id` stays `edit_endpoint`'s job (entities.md §9: "the same door every +other field uses"). + +Client registration (specs-wp20.md) is a two-strategy choice made once per `begin()`: +reuse a stored outbound registration if one exists, else prefer the client identity +document, else register outbound and store the result. `complete()` never re-decides — +the choice travels in `state` (`strategy`) so a later DNS answer can't disagree with the +one the authorization_url was actually built from. +""" + +from typing import List, Optional, Tuple +from uuid import UUID + +from mcp.shared.auth import OAuthClientInformationFull + +from oss.src.core.gateways.mcps.oauth.client import MCPOAuthClient +from oss.src.core.gateways.mcps.oauth.dtos import ( + MCPOAuthAuthorizationStart, + MCPOAuthCompletion, + MCPOAuthDiscovery, +) +from oss.src.core.gateways.mcps.oauth.registration import ( + Resolver, + identity_document_client_info, + is_publicly_resolvable, +) +from oss.src.core.gateways.mcps.oauth.state import decode_state, make_state +from oss.src.core.gateways.mcps.oauth.storage import SecretsTokenStorage +from oss.src.core.gateways.mcps.oauth.types import ( + MCPOAuthClientNotRegisteredError, + MCPOAuthStateInvalidError, +) +from oss.src.core.secrets.services import VaultService + +_CALLBACK_PATH = "/gateways/mcps/connect/callback" + + +def callback_redirect_uri(*, api_url: str) -> str: + """The fixed redirect URI registered with every authorization server — never + per-flow (specs-wp17.md: disambiguated by `state`, not by the URL).""" + return f"{api_url.rstrip('/')}{_CALLBACK_PATH}" + + +class MCPOAuthConnectService: + def __init__( + self, + *, + vault_service: VaultService, + client: MCPOAuthClient, + api_url: str, + secret_key: str, + resolve: Optional[Resolver] = None, + ) -> None: + self.vault_service = vault_service + self.client = client + self.api_url = api_url + self.secret_key = secret_key + # None -> real DNS (registration.py's own default); tests inject a fake. + self._resolve_kwargs = {"resolve": resolve} if resolve is not None else {} + + async def discover(self, *, server_url: str) -> MCPOAuthDiscovery: + return await self.client.discover(server_url=server_url) + + async def _resolve_client_info( + self, + *, + storage: SecretsTokenStorage, + discovery: MCPOAuthDiscovery, + redirect_uri: str, + scopes: List[str], + ) -> Tuple[OAuthClientInformationFull, str]: + """The strategy choice (specs-wp20.md). Order: reuse a stored outbound + registration if this authorization_server already has one (stability over + re-optimizing an already-working connection); else prefer the document; else + register outbound and store it, exactly as WP17 always did.""" + stored = await storage.get_client_info() + if stored is not None: + return stored, "outbound" + + if is_publicly_resolvable(self.api_url, **self._resolve_kwargs): + return ( + identity_document_client_info( + api_url=self.api_url, redirect_uri=redirect_uri + ), + "document", + ) + + registered = await self.client.register( + authorization_server=discovery.authorization_server, + registration_endpoint=discovery.registration_endpoint, + redirect_uri=redirect_uri, + scopes=scopes, + ) + await storage.set_client_info(registered) + return registered, "outbound" + + async def begin( + self, + *, + project_id: UUID, + user_id: UUID, + server_url: str, + scopes: List[str], + ) -> MCPOAuthAuthorizationStart: + discovery = await self.client.discover(server_url=server_url) + redirect_uri = callback_redirect_uri(api_url=self.api_url) + + storage = SecretsTokenStorage( + vault_service=self.vault_service, + project_id=project_id, + server_url=server_url, + authorization_server=discovery.authorization_server, + ) + + client_info, strategy = await self._resolve_client_info( + storage=storage, + discovery=discovery, + redirect_uri=redirect_uri, + scopes=scopes, + ) + + pkce = self.client.build_pkce() + state = make_state( + project_id=project_id, + user_id=user_id, + server_url=server_url, + code_verifier=pkce.code_verifier, + scopes=scopes, + secret_key=self.secret_key, + strategy=strategy, + ) + + authorization_url = self.client.authorization_url( + authorization_endpoint=discovery.authorization_endpoint, + client_id=client_info.client_id or "", + redirect_uri=redirect_uri, + code_challenge=pkce.code_challenge, + state=state, + scopes=scopes, + resource=discovery.resource, + ) + + return MCPOAuthAuthorizationStart( + authorization_url=authorization_url, state=state + ) + + async def complete(self, *, code: str, state: str) -> MCPOAuthCompletion: + payload = decode_state(state, secret_key=self.secret_key) + if payload is None: + raise MCPOAuthStateInvalidError() + + project_id = UUID(payload["project_id"]) + server_url = payload["server_url"] + code_verifier = payload["code_verifier"] + strategy = payload.get("strategy", "outbound") + + discovery = await self.client.discover(server_url=server_url) + redirect_uri = callback_redirect_uri(api_url=self.api_url) + + storage = SecretsTokenStorage( + vault_service=self.vault_service, + project_id=project_id, + server_url=server_url, + authorization_server=discovery.authorization_server, + ) + + if strategy == "document": + # Deterministic, never stored (specs-wp20.md) — nothing to look up. + client_info = identity_document_client_info( + api_url=self.api_url, redirect_uri=redirect_uri + ) + else: + client_info = await storage.get_client_info() + if client_info is None: + raise MCPOAuthClientNotRegisteredError(server_url=server_url) + + tokens = await self.client.exchange_token( + token_endpoint=discovery.token_endpoint, + code=code, + code_verifier=code_verifier, + redirect_uri=redirect_uri, + client_info=client_info, + resource=discovery.resource, + ) + grant = await storage.write_tokens(tokens) + + return MCPOAuthCompletion( + project_id=project_id, server_url=server_url, secret_id=grant.id + ) diff --git a/api/oss/src/core/gateways/mcps/oauth/state.py b/api/oss/src/core/gateways/mcps/oauth/state.py new file mode 100644 index 0000000000..7a3aa65c6b --- /dev/null +++ b/api/oss/src/core/gateways/mcps/oauth/state.py @@ -0,0 +1,89 @@ +"""The OAuth `state` token (specs-wp17.md "The state token"). + +Same HMAC-signed, base64url shape as `core/gateway/connections/utils.py`'s +`make_oauth_state`/`decode_oauth_state`, reimplemented here rather than imported to keep +this package independent of the Composio/connections domain (specs-wp17.md: "target: +custom only"). Carries the PKCE code_verifier across the browser round trip — there is no +server-side session to hold it, and the request may land on any replica. +""" + +import base64 +import hashlib +import hmac +import json +import secrets +import time +from typing import List, Optional, TypedDict +from uuid import UUID + +_STATE_TTL_SECONDS = 3600 + + +class MCPOAuthStatePayload(TypedDict): + project_id: str + user_id: str + server_url: str + code_verifier: str + scopes: List[str] + strategy: str + nonce: str + ts: int + + +def make_state( + *, + project_id: UUID, + user_id: UUID, + server_url: str, + code_verifier: str, + scopes: List[str], + secret_key: str, + strategy: str = "outbound", +) -> str: + """`strategy` ("outbound" | "document", specs-wp20.md) rides the state so + `complete()` rebuilds the same client_info deterministically instead of + re-probing reachability at callback time.""" + payload = { + "project_id": str(project_id), + "user_id": str(user_id), + "server_url": server_url, + "code_verifier": code_verifier, + "scopes": scopes, + "strategy": strategy, + "nonce": secrets.token_hex(8), + "ts": int(time.time()), + } + payload_bytes = json.dumps(payload, sort_keys=True).encode() + payload_b64 = base64.urlsafe_b64encode(payload_bytes).decode().rstrip("=") + sig = hmac.new( + secret_key.encode(), payload_b64.encode(), hashlib.sha256 + ).hexdigest() + return f"{payload_b64}.{sig}" + + +def decode_state( + state: str, + *, + secret_key: str, + max_age: int = _STATE_TTL_SECONDS, +) -> Optional[MCPOAuthStatePayload]: + try: + payload_b64, sig = state.rsplit(".", 1) + expected_sig = hmac.new( + secret_key.encode(), payload_b64.encode(), hashlib.sha256 + ).hexdigest() + if not hmac.compare_digest(sig, expected_sig): + return None + + padding = 4 - len(payload_b64) % 4 + if padding != 4: + payload_b64 += "=" * padding + + payload = json.loads(base64.urlsafe_b64decode(payload_b64)) + + if time.time() - payload.get("ts", 0) > max_age: + return None + + return payload + except Exception: + return None diff --git a/api/oss/src/core/gateways/mcps/oauth/storage.py b/api/oss/src/core/gateways/mcps/oauth/storage.py new file mode 100644 index 0000000000..7ad406ada0 --- /dev/null +++ b/api/oss/src/core/gateways/mcps/oauth/storage.py @@ -0,0 +1,187 @@ +"""`SecretsTokenStorage` (specs-wp17.md "The storage adapter"). + +Structurally satisfies `mcp.client.auth.oauth2.TokenStorage` — a `Protocol`, so no +subclassing is required — over `VaultService`. One instance per `(project_id, +server_url)`. No new `SecretsDAOInterface` method: lookups are the same list+filter scan +`SecretsResolver._match_provider_secret` already uses for `ProviderKeyRef`. +""" + +import time +from typing import Optional +from uuid import NAMESPACE_URL, UUID, uuid5 + +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + +from oss.src.core.secrets.dtos import ( + CreateSecretDTO, + OAuthGrantDTO, + OAuthGrantSettingsDTO, + OAuthProviderDTO, + OAuthProviderSettingsDTO, + SecretDTO, + SecretResponseDTO, + UpdateSecretDTO, +) +from oss.src.core.secrets.enums import SecretKind +from oss.src.core.secrets.services import VaultService +from oss.src.core.shared.dtos import Header +from oss.src.utils.helpers import get_slug_from_name_and_id + + +def _server_slug(server_url: str) -> str: + return get_slug_from_name_and_id("oauth-grant", uuid5(NAMESPACE_URL, server_url)) + + +def _issuer_slug(issuer_url: str) -> str: + return get_slug_from_name_and_id("oauth-provider", uuid5(NAMESPACE_URL, issuer_url)) + + +class SecretsTokenStorage: + """`get_tokens`/`set_tokens` read/write an `oauth_grant` secret keyed by + `server_url`; `get_client_info`/`set_client_info` read/write an `oauth_provider` + secret keyed by the discovered `authorization_server` issuer once known, else by + `server_url` (specs-wp17.md's "Keys"). One `oauth_grant` per project per server — + entities.md's "project-owned, full stop", not per user (`SecretOwnerKind.USER` has + no live lookup yet).""" + + def __init__( + self, + *, + vault_service: VaultService, + project_id: UUID, + server_url: str, + authorization_server: Optional[str] = None, + ) -> None: + self.vault_service = vault_service + self.project_id = project_id + self.server_url = server_url + self.authorization_server = authorization_server + + # --- tokens: keyed by server_url --------------------------------------- # + + async def _find_grant(self) -> Optional[SecretResponseDTO]: + secrets = await self.vault_service.list_secrets(project_id=self.project_id) + return next( + ( + s + for s in secrets + if s.kind == SecretKind.OAUTH_GRANT + and s.data.grant.server == self.server_url + ), + None, + ) + + async def get_tokens(self) -> Optional[OAuthToken]: + grant = await self._find_grant() + if grant is None: + return None + data = grant.data.grant + expires_in = ( + int(data.expires_at - time.time()) if data.expires_at is not None else None + ) + return OAuthToken( + access_token=data.access_token, + # OAuthGrantSettingsDTO has no token_type field; MCP authorization + # mandates Bearer, so nothing is actually lost by fixing it here. + token_type="Bearer", + expires_in=expires_in, + scope=" ".join(data.scopes) if data.scopes else None, + refresh_token=data.refresh_token, + ) + + async def set_tokens(self, tokens: OAuthToken) -> None: + await self.write_tokens(tokens) + + async def write_tokens(self, tokens: OAuthToken) -> SecretResponseDTO: + """Same write as `set_tokens`, returning the written row — the extra + return value `TokenStorage` has no room for but `MCPOAuthConnectService. + complete()` needs (the `secret_id` WP18 PUTs onto the endpoint).""" + expires_at = ( + int(time.time()) + tokens.expires_in + if tokens.expires_in is not None + else None + ) + grant_settings = OAuthGrantSettingsDTO( + server=self.server_url, + access_token=tokens.access_token, + refresh_token=tokens.refresh_token, + expires_at=expires_at, + scopes=tokens.scope.split() if tokens.scope else [], + ) + secret = SecretDTO( + kind=SecretKind.OAUTH_GRANT, + data=OAuthGrantDTO(grant=grant_settings), + ) + + existing = await self._find_grant() + if existing is not None: + updated = await self.vault_service.update_secret( + secret_id=existing.id, + update_secret_dto=UpdateSecretDTO(secret=secret), + project_id=self.project_id, + ) + return updated or existing + + return await self.vault_service.create_secret( + project_id=self.project_id, + create_secret_dto=CreateSecretDTO( + slug=_server_slug(self.server_url), + header=Header(name=f"OAuth grant — {self.server_url}"), + secret=secret, + ), + ) + + # --- client registration: keyed by issuer, falling back to server_url --- # + + async def _find_provider(self) -> Optional[SecretResponseDTO]: + target = self.authorization_server or self.server_url + secrets = await self.vault_service.list_secrets(project_id=self.project_id) + return next( + ( + s + for s in secrets + if s.kind == SecretKind.OAUTH_PROVIDER + and s.data.provider.issuer_url == target + ), + None, + ) + + async def get_client_info(self) -> Optional[OAuthClientInformationFull]: + provider = await self._find_provider() + if provider is None: + return None + return OAuthClientInformationFull.model_validate( + provider.data.provider.extra["client_info"] + ) + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + issuer = self.authorization_server or self.server_url + provider_settings = OAuthProviderSettingsDTO( + client_id=client_info.client_id or "", + client_secret=client_info.client_secret or "", + issuer_url=issuer, + scopes=(client_info.scope or "").split(), + extra={"client_info": client_info.model_dump(mode="json")}, + ) + secret = SecretDTO( + kind=SecretKind.OAUTH_PROVIDER, + data=OAuthProviderDTO(provider=provider_settings), + ) + + existing = await self._find_provider() + if existing is not None: + await self.vault_service.update_secret( + secret_id=existing.id, + update_secret_dto=UpdateSecretDTO(secret=secret), + project_id=self.project_id, + ) + return + + await self.vault_service.create_secret( + project_id=self.project_id, + create_secret_dto=CreateSecretDTO( + slug=_issuer_slug(issuer), + header=Header(name=f"OAuth client — {issuer}"), + secret=secret, + ), + ) diff --git a/api/oss/src/core/gateways/mcps/oauth/types.py b/api/oss/src/core/gateways/mcps/oauth/types.py new file mode 100644 index 0000000000..62e3831fcd --- /dev/null +++ b/api/oss/src/core/gateways/mcps/oauth/types.py @@ -0,0 +1,51 @@ +"""Domain exceptions for the MCP OAuth client (specs-wp17.md).""" + +from typing import Optional + +from oss.src.core.gateways.types import GatewaysError + + +class MCPOAuthDiscoveryError(GatewaysError): + """No protected-resource or authorization-server metadata could be found.""" + + def __init__(self, *, server_url: str, detail: Optional[str] = None): + self.server_url = server_url + self.detail = detail + super().__init__(f"OAuth discovery failed for {server_url}: {detail}") + + +class MCPOAuthRegistrationError(GatewaysError): + """RFC 7591 dynamic client registration failed.""" + + def __init__(self, *, authorization_server: str, detail: Optional[str] = None): + self.authorization_server = authorization_server + self.detail = detail + super().__init__( + f"Client registration failed at {authorization_server}: {detail}" + ) + + +class MCPOAuthTokenExchangeError(GatewaysError): + """The token endpoint refused the authorization-code exchange.""" + + def __init__(self, *, token_endpoint: str, detail: Optional[str] = None): + self.token_endpoint = token_endpoint + self.detail = detail + super().__init__(f"Token exchange failed at {token_endpoint}: {detail}") + + +class MCPOAuthStateInvalidError(GatewaysError): + """The callback's `state` parameter did not verify — tampered or expired.""" + + def __init__(self): + super().__init__("OAuth state is invalid or expired") + + +class MCPOAuthClientNotRegisteredError(GatewaysError): + """The state verified, but no client registration is on file for its + authorization server — `begin()` always registers first, so this means the + registration was deleted between the redirect and the callback.""" + + def __init__(self, *, server_url: str): + self.server_url = server_url + super().__init__(f"No OAuth client registration on file for {server_url}") diff --git a/api/oss/src/core/gateways/mcps/providers/http/__init__.py b/api/oss/src/core/gateways/mcps/providers/http/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/core/gateways/mcps/providers/http/adapter.py b/api/oss/src/core/gateways/mcps/providers/http/adapter.py new file mode 100644 index 0000000000..bc925a0927 --- /dev/null +++ b/api/oss/src/core/gateways/mcps/providers/http/adapter.py @@ -0,0 +1,164 @@ +"""HttpMCPAdapter: the south-port relay for `custom` MCP servers (entities.md §7.1). + +Registered under the `"http"` key (WP9's `MCPUpstreamRegistry`) and reached only via the +`custom` namespace: `agenta` targets route to the mocks/agenta adapters and `builtin` +targets route to `ComposioMCPAdapter` (out of this wave). Because only `custom` URLs are +ever handed to this class, the SSRF guard (D28) runs unconditionally on every call rather +than branching on a namespace this port is never given. +""" + +from typing import Dict, Optional, Set, Tuple +from urllib.parse import urlparse, urlunparse + +import httpx + +from oss.src.core.gateways.mcps.dtos import ( + MCPBrokeredAuth, + MCPCallContext, + MCPDirectAuth, + MCPRelayAuth, + MCPResolvedRoute, +) +from oss.src.core.gateways.dtos import GATEWAY_ONLY_HEADERS +from oss.src.core.gateways.mcps.interfaces import MCPRelayResult, MCPUpstreamInterface +from oss.src.core.gateways.mcps.types import MCPUpstreamError +from oss.src.core.webhooks.utils import resolve_validated_webhook_ip +from oss.src.utils.env import env + +_DEFAULT_TIMEOUT_SECONDS = 30.0 + + +def _host_allowlist() -> Set[str]: + return {h.strip().lower() for h in env.mcp_gateway.host_allowlist if h.strip()} + + +def _drop_header(headers: Dict[str, str], name: str) -> Dict[str, str]: + return {k: v for k, v in headers.items() if k.lower() != name.lower()} + + +def _drop_gateway_headers(headers: Dict[str, str]) -> Dict[str, str]: + """Our own credentials never reach a third-party server (D31). Everything else the + caller sent is forwarded, and only a resolved secret overwrites `Authorization`.""" + return {k: v for k, v in headers.items() if k.lower() not in GATEWAY_ONLY_HEADERS} + + +def _pin_to_resolved_ip(url: str, resolved_ip: str) -> Tuple[str, str]: + """Swap the URL host for the literal resolved IP; return (pinned_url, host_header). + + Copies the pinning in `core/webhooks/delivery.py::send_webhook_request` so a + DNS-rebind between validation and connect cannot reach a different host than the + one the guard just checked. + """ + 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 + pinned_url = urlunparse(parsed._replace(netloc=pinned_netloc)) + + hostname = parsed.hostname or "" + host_header = f"[{hostname}]" if ":" in hostname else hostname + if parsed.port: + host_header = f"{host_header}:{parsed.port}" + + return pinned_url, host_header + + +def _authorization_header(auth: MCPDirectAuth) -> Optional[str]: + """Derive `Authorization` from a resolved OAuth grant, when present. + + `OAuthGrantSettingsDTO` (entities.md §4.5) doesn't exist in this codebase yet — it + is WP16 seed, wave 3 — so its future `.grant.access_token` / `.grant.token_type` + shape is read defensively via `getattr` rather than imported, and this needs no + change once it lands. Unreachable in Checkpoint A (D23): no OAuth targets exist yet. + """ + if auth.secret is None: + return None + + grant = getattr(auth.secret.secret.data, "grant", None) + access_token = getattr(grant, "access_token", None) if grant is not None else None + if not access_token: + return None + + token_type = getattr(grant, "token_type", None) or "Bearer" + return f"{token_type} {access_token}" + + +class HttpMCPAdapter(MCPUpstreamInterface): + """Streamable HTTP relay for `custom` MCP servers. Transparent per D16: the body + and the upstream's response travel byte-for-byte; only the route and the + authorization change.""" + + def __init__(self, *, transport: Optional[httpx.BaseTransport] = None) -> None: + # Injectable seam for unit tests (an httpx.MockTransport standing in for a real + # upstream, per specs-wp8.md's test layer); None keeps the wiring-site + # `HttpMCPAdapter()` call unchanged and uses httpx's normal transport. + self._transport = transport + + async def relay( + self, + *, + route: MCPResolvedRoute, + auth: MCPRelayAuth, + # + context: MCPCallContext, # unused: no JSON-RPC parsing here (§7.1) + body: bytes, + headers: Dict[str, str], + ) -> MCPRelayResult: + if isinstance(auth, MCPBrokeredAuth): + raise TypeError( + "HttpMCPAdapter relays MCPDirectAuth only; MCPBrokeredAuth (builtin) " + "belongs to ComposioMCPAdapter" + ) + + parsed = urlparse(route.url) + hostname = (parsed.hostname or "").lower() + + # route.headers merged under the caller's forwarded headers (§7.1): caller + # headers win on collision. The caller's own `Host` referred to this gateway, + # never the upstream, so it is dropped either way. + merged_headers = _drop_gateway_headers( + _drop_header({**route.headers, **headers}, "Host") + ) + + if hostname in _host_allowlist(): + target_url = route.url + else: + try: + resolved_ip = resolve_validated_webhook_ip(route.url) + except ValueError as e: + message = str(e) + # Keep a DNS typo distinct from a security rejection (runner precedent, + # services/runner/src/engines/sandbox_agent/mcp.ts:191). + detail = ( + message + if "could not be resolved" in message + else f"blocked target: {message}" + ) + raise MCPUpstreamError(target=route.url, detail=detail) from e + + target_url, host_header = _pin_to_resolved_ip(route.url, resolved_ip) + merged_headers["Host"] = host_header + + authorization = _authorization_header(auth) + if authorization is not None: + merged_headers["Authorization"] = authorization + + timeout = route.settings.timeout_seconds or _DEFAULT_TIMEOUT_SECONDS + + try: + async with httpx.AsyncClient( + timeout=timeout, transport=self._transport + ) as client: + response = await client.post( + target_url, + content=body, + headers=merged_headers, + extensions={"sni_hostname": parsed.hostname}, + ) + except httpx.RequestError as e: + raise MCPUpstreamError(target=route.url, detail=str(e)) from e + + return MCPRelayResult( + status_code=response.status_code, + headers=dict(response.headers), + body=response.content, + ) diff --git a/api/oss/src/core/gateways/mcps/providers/mock/__init__.py b/api/oss/src/core/gateways/mcps/providers/mock/__init__.py new file mode 100644 index 0000000000..e3cd49c517 --- /dev/null +++ b/api/oss/src/core/gateways/mcps/providers/mock/__init__.py @@ -0,0 +1 @@ +"""The mock MCP upstream (D23, WP5): MockMCPAdapter and its deployable app.""" diff --git a/api/oss/src/core/gateways/mcps/providers/mock/adapter.py b/api/oss/src/core/gateways/mcps/providers/mock/adapter.py new file mode 100644 index 0000000000..6a652dd894 --- /dev/null +++ b/api/oss/src/core/gateways/mcps/providers/mock/adapter.py @@ -0,0 +1,137 @@ +"""MockMCPAdapter: the in-process mock MCP upstream (entities.md §7.1, D23). + +Unlike the real `http`/`composio` adapters, this one *is* the upstream — it +parses `body` (the caller's JSON-RPC payload) itself and answers in-process, +because there is nothing behind it to relay to. This does not violate D16's +transparency rule: D16 constrains the gateway, which still passes `body` +through this port untouched; 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`: + + echo echoes params.arguments back as the tool result content + fail a JSON-RPC *result* carrying isError: true — never raised. A + tool's own business failure is not a transport failure (D16, + api/AGENTS.md's pass-through rule). + slow sleeps params.arguments.seconds (default 5), then a fixed result + +`notifications/*` returns 202 with an empty body, matching the runner's +internal MCP server (services/runner/src/tools/tool-mcp-http.ts). Any other +method is a transport-level failure: MCPUpstreamError(status_code=501) — there +is no fourth method to mock. + +Forced scope challenges (MCPScopeInsufficientError) are explicitly not built +here (D23's "later"): that type is unreachable until the OAuth checkpoint +(wave 3); adding a tool that raises it now would exercise a 403-handling path +that does not exist yet. Extension point: a `scope-challenge` tool would live +in _TOOLS + _dispatch_tool_call once that checkpoint lands. +""" + +import asyncio +import json +from typing import Any, Dict + +from oss.src.core.gateways.mcps.dtos import ( + MCPCallContext, + MCPRelayAuth, + MCPResolvedRoute, +) +from oss.src.core.gateways.mcps.interfaces import MCPRelayResult, MCPUpstreamInterface +from oss.src.core.gateways.mcps.types import MCPUpstreamError + +_PROTOCOL_VERSION = "2026-07-28" # pinned per MCPCallContext's own docstring + +_TOOLS = [ + { + "name": "echo", + "description": "Echoes the given arguments back as the tool result.", + "inputSchema": {"type": "object", "additionalProperties": True}, + }, + { + "name": "fail", + "description": "Always returns a tool-level error result (isError: true).", + "inputSchema": {"type": "object", "additionalProperties": True}, + }, + { + "name": "slow", + "description": "Sleeps `seconds` (default 5) before returning a fixed result.", + "inputSchema": { + "type": "object", + "properties": {"seconds": {"type": "integer"}}, + }, + }, +] + + +def _tool_result(text: str, *, is_error: bool = False) -> Dict[str, Any]: + return {"content": [{"type": "text", "text": text}], "isError": is_error} + + +async def _dispatch_tool_call(params: Dict[str, Any]) -> Dict[str, Any]: + name = params.get("name") + arguments = params.get("arguments") or {} + + if name == "echo": + return _tool_result(json.dumps(arguments), is_error=False) + + if name == "fail": + return _tool_result("mock/fail: forced tool failure", is_error=True) + + if name == "slow": + seconds = arguments.get("seconds", 5) + await asyncio.sleep(seconds) + return _tool_result(f"slept {seconds}s", is_error=False) + + return _tool_result(f"unknown tool: {name}", is_error=True) + + +def _initialize_result() -> Dict[str, Any]: + return { + "protocolVersion": _PROTOCOL_VERSION, + "serverInfo": {"name": "agenta-mock-mcp", "version": "0.1.0"}, + "capabilities": {"tools": {}}, + } + + +class MockMCPAdapter(MCPUpstreamInterface): + async def relay( + self, + *, + route: MCPResolvedRoute, + auth: MCPRelayAuth, + # + context: MCPCallContext, + body: bytes, + headers: Dict[str, str], + ) -> MCPRelayResult: + try: + payload = json.loads(body) if body else {} + except (json.JSONDecodeError, TypeError): + payload = {} + + method = payload.get("method") or context.method or "" + request_id = payload.get("id") + + if method.startswith("notifications/"): + return MCPRelayResult(status_code=202, headers={}, body=b"") + + if method == "initialize": + result = _initialize_result() + elif method == "tools/list": + result = {"tools": _TOOLS} + elif method == "tools/call": + result = await _dispatch_tool_call(payload.get("params") or {}) + else: + raise MCPUpstreamError( + target=route.url, + status_code=501, + detail=f"unsupported method: {method}", + ) + + response = {"jsonrpc": "2.0", "id": request_id, "result": result} + return MCPRelayResult( + status_code=200, + headers={"content-type": "application/json"}, + body=json.dumps(response).encode(), + ) diff --git a/api/oss/src/core/gateways/mcps/providers/mock/app.py b/api/oss/src/core/gateways/mcps/providers/mock/app.py new file mode 100644 index 0000000000..7d3bc20195 --- /dev/null +++ b/api/oss/src/core/gateways/mcps/providers/mock/app.py @@ -0,0 +1,89 @@ +"""Deployable mock MCP Streamable HTTP server (entities.md §0, D23, WP5). + +A standalone ASGI app (`uvicorn oss.src.core.gateways.mcps.providers.mock.app:app`), +not mounted into the main API process. Stateless JSON mode: one JSON-RPC request in, +one `application/json` response out, `202` for a notification — the same shape as +the runner's internal tool server (services/runner/src/tools/tool-mcp-http.ts), no +session id, no SSE leg. `GET`/`DELETE` answer `405`. + +Delegates every POST straight to `MockMCPAdapter` so both tiers share one +implementation of the control convention. +""" + +import json + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, Response + +from oss.src.core.gateways.mcps.dtos import ( + MCPCallContext, + MCPDirectAuth, + MCPResolvedRoute, +) +from oss.src.core.gateways.mcps.providers.mock.adapter import MockMCPAdapter +from oss.src.core.gateways.mcps.types import MCPUpstreamError + +app = FastAPI(title="agenta-mock-mcp-gateway") +_adapter = MockMCPAdapter() +_route = MCPResolvedRoute(url="http://mock-mcp-gateway:9092/") +_auth = MCPDirectAuth(secret=None) + + +@app.get("/health") +async def health() -> Response: + return Response(status_code=200) + + +@app.post("/__echo") +async def echo_headers(request: Request) -> Response: + """Report the headers this process received (launch-2.md D39). + + Reachable through the gateway by pointing an endpoint's `base_url` at `/__echo`: the MCP + relay POSTs to `base_url` directly, so the answer is what the upstream really saw. + """ + return JSONResponse(content={"headers": dict(request.headers)}) + + +@app.post("/") +async def relay(request: Request) -> Response: + body = await request.body() + try: + payload = json.loads(body) if body else {} + except (json.JSONDecodeError, TypeError): + payload = {} + + context = MCPCallContext(method=payload.get("method", "")) + + try: + result = await _adapter.relay( + route=_route, + auth=_auth, + context=context, + body=body, + headers=dict(request.headers), + ) + except MCPUpstreamError as exc: + return Response( + status_code=exc.status_code or 502, + content=(exc.detail or str(exc)).encode(), + media_type="text/plain", + ) + + if result.status_code == 202: + return Response(status_code=202) + + return Response( + status_code=result.status_code, + content=result.body, + media_type="application/json", + ) + + +@app.get("/") +async def reject_get() -> Response: + return Response(status_code=405) + + +@app.delete("/") +async def reject_delete() -> Response: + return Response(status_code=405) diff --git a/api/oss/src/core/gateways/mcps/registry.py b/api/oss/src/core/gateways/mcps/registry.py new file mode 100644 index 0000000000..9597f77093 --- /dev/null +++ b/api/oss/src/core/gateways/mcps/registry.py @@ -0,0 +1,32 @@ +"""`MCPUpstreamRegistry` — adapter-key dispatch for the MCP south port (entities.md §7.1). + +Shape copied verbatim from `ConnectionsGatewayRegistry` +(`core/gateway/connections/registry.py`) per specs-wp9.md: the fourth structurally +identical registry in this codebase. `core/gateways/mcps/interfaces.py` declares only the DAO +interfaces and the south port — entities.md §7.1 shows the registry in the same code +fence, which is presentation, not placement (R13). This module is where it lives. +""" + +from typing import Dict + +from oss.src.core.gateways.mcps.interfaces import MCPUpstreamInterface +from oss.src.core.gateways.mcps.types import MCPUpstreamError + + +class MCPUpstreamRegistry: + def __init__(self, *, adapters: Dict[str, MCPUpstreamInterface]) -> None: + self._adapters = adapters + + def get(self, key: str) -> MCPUpstreamInterface: + adapter = self._adapters.get(key) + if adapter is None: + # No dedicated registry-miss exception is named by entities.md §7.1 + # (specs-wp9.md, "Missing from the design") — MCPUpstreamError repurposed + # with `target` naming the missing key rather than a URL. + raise MCPUpstreamError( + target=key, detail=f"no upstream adapter registered for {key!r}" + ) + return adapter + + def keys(self) -> list[str]: + return list(self._adapters.keys()) diff --git a/api/oss/src/core/gateways/mcps/service.py b/api/oss/src/core/gateways/mcps/service.py new file mode 100644 index 0000000000..3b0761bf91 --- /dev/null +++ b/api/oss/src/core/gateways/mcps/service.py @@ -0,0 +1,674 @@ +"""`MCPGatewayService`: management + the transparent proxy (entities.md §8, WP9). + +Wave 1 fills every method except the parts +of `relay` that would reach a brokered `builtin` target — D23: no such target is +reachable yet, and `ComposioMCPAdapter` has no owning package in this wave. +""" + +import json +import re +from dataclasses import dataclass +from typing import Dict, List, Optional +from uuid import UUID + +from oss.src.core.access.permissions.types import Permission +from oss.src.core.gateway.connections.dtos import Connection +from oss.src.core.gateway.connections.service import ConnectionsService +from oss.src.core.gateways.mcps.dtos import MCPAuthScheme +from oss.src.core.gateways.dtos import ( + GatewayConnectionState, + GatewayEndpointNamespace, +) +from oss.src.core.gateways.mcps.dtos import ( + AGENTA_PROVIDER, + COMPOSIO_PROVIDER, + MCPBrokeredAuth, + MCPCallContext, + MCPDirectAuth, + MCPEndpoint, + MCPEndpointCreate, + MCPEndpointData, + MCPEndpointRoute, + MCPEndpointEdit, + MCPEndpointQuery, + MCPRelayAuth, + MCPResolvedRoute, + MCPToolFilter, +) +from oss.src.core.gateways.mcps.interfaces import ( + MCPEndpointsDAOInterface, + MCPRelayResult, +) +from oss.src.core.gateways.mcps.registry import MCPUpstreamRegistry +from oss.src.core.gateways.types import GatewayEndpointInactiveError +from oss.src.core.gateways.mcps.types import ( + MCPEndpointNotFoundError, + MCPScopeInsufficientError, + MCPToolNotAllowedError, + MCPUpstreamError, +) +from oss.src.core.gateways.policy.dtos import ( + BoundSecretRef, + SecretOwnerKind, + SecretMode, + GatewayOutcome, + GatewayPlane, + GatewayTarget, +) +from oss.src.core.gateways.policy.interfaces import SecretsResolverInterface +from oss.src.core.gateways.policy.service import GatewayPolicyService +from oss.src.core.gateways.policy.types import ( + PolicyDeniedError, + SecretInvalidError, + SecretNotFoundError, +) +from oss.src.core.shared.dtos import Windowing +from oss.src.utils.context import AuthScope +from oss.src.utils.env import env + +# Adapter selection (§8 step 5). `builtin` dispatches on its provider segment, since +# the namespace only says whose account pays, not which backend answers (D30). +_BUILTIN_ADAPTER_KEYS: Dict[str, str] = { + AGENTA_PROVIDER: "mock", + COMPOSIO_PROVIDER: "composio", +} + + +def _adapter_key( + *, namespace: GatewayEndpointNamespace, provider: Optional[str] +) -> str: + if namespace == GatewayEndpointNamespace.BUILTIN: + key = _BUILTIN_ADAPTER_KEYS.get(provider or "") + if key is None: + raise MCPEndpointNotFoundError(namespace=namespace, name=provider or "") + return key + if namespace == GatewayEndpointNamespace.CUSTOM: + return "http" + # STANDARD: reserved, empty on the MCP plane (D30). + raise MCPEndpointNotFoundError(namespace=namespace, name="") + + +@dataclass +class _ResolvedTarget: + """Service-internal only (§8: "never crosses a layer... not a DTO in §4"). + `endpoint` is always populated (generated or a row) so allowlist/auth/route + logic reads it uniformly; `connection` is set only for `builtin`, carrying the + raw row `MCPBrokeredAuth` wraps.""" + + namespace: GatewayEndpointNamespace + name: str + endpoint: MCPEndpoint + provider: Optional[str] = None + integration: Optional[str] = None + connection: Optional[Connection] = None + + +_INSUFFICIENT_SCOPE_RE = re.compile( + r"""\berror\s*=\s*"insufficient_scope"|\berror\s*=\s*insufficient_scope\b""" +) +_SCOPE_PARAM_RE = re.compile(r'\bscope\s*=\s*"([^"]*)"') + + +def _parse_scope_challenge(headers: Dict[str, str]) -> Optional[List[str]]: + """RFC 6750 `WWW-Authenticate: Bearer error="insufficient_scope", scope="a b"` — + the step-up challenge (D17, WP19). `None` when the response isn't one; `[]` when + it is but the upstream omitted `scope` (WP18's dialog re-discovers the offered set + either way, so a missing list degrades to "reopen the checklist", not a failure).""" + header = next( + (v for k, v in headers.items() if k.lower() == "www-authenticate"), None + ) + if not header or not _INSUFFICIENT_SCOPE_RE.search(header): + return None + match = _SCOPE_PARAM_RE.search(header) + return match.group(1).split() if match else [] + + +def _target_path( + *, + namespace: GatewayEndpointNamespace, + provider: Optional[str], + integration: Optional[str], + name: str, +) -> str: + return "/".join(s for s in (namespace.value, provider, integration, name) if s) + + +class MCPGatewayService: + def __init__( + self, + *, + mcp_endpoints_dao: MCPEndpointsDAOInterface, + policy: GatewayPolicyService, + resolver: SecretsResolverInterface, + upstream_registry: MCPUpstreamRegistry, + # Not in entities.md §8's abbreviated constructor pseudocode, but §8's own prose + # ("connection state resolved through the existing connections service") and + # specs-wp9.md both require calling ConnectionsService.query_connections / + # get_connection for real, in list_endpoints and in relay's builtin branch alike + # ("the same instance list_endpoints already uses"). The abbreviated signature is + # a gap in the design, not a instruction to mock the integration; flagged for + # the M2 merge review rather than silently added. + connections_service: ConnectionsService, + ) -> None: + self.mcp_endpoints_dao = mcp_endpoints_dao + self.policy = policy + self.resolver = resolver + self.upstream_registry = upstream_registry + self.connections_service = connections_service + + # --- management: thin DAO delegation ------------------------------------- # + + async def create_endpoint( + self, + *, + project_id: UUID, + user_id: UUID, + # + endpoint: MCPEndpointCreate, + ) -> Optional[MCPEndpoint]: + return await self.mcp_endpoints_dao.create_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=endpoint, + ) + + async def fetch_endpoint( + self, + *, + project_id: UUID, + # + endpoint_id: UUID, + ) -> Optional[MCPEndpoint]: + return await self.mcp_endpoints_dao.fetch_endpoint( + project_id=project_id, + # + endpoint_id=endpoint_id, + ) + + async def edit_endpoint( + self, + *, + project_id: UUID, + user_id: UUID, + # + endpoint: MCPEndpointEdit, + ) -> Optional[MCPEndpoint]: + return await self.mcp_endpoints_dao.edit_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=endpoint, + ) + + async def delete_endpoint( + self, + *, + project_id: UUID, + # + endpoint_id: UUID, + ) -> bool: + return await self.mcp_endpoints_dao.delete_endpoint( + project_id=project_id, + # + endpoint_id=endpoint_id, + ) + + async def query_endpoints( + self, + *, + project_id: UUID, + # + endpoint: Optional[MCPEndpointQuery] = None, + # + windowing: Optional[Windowing] = None, + ) -> List[MCPEndpoint]: + return await self.mcp_endpoints_dao.query_endpoints( + project_id=project_id, + # + endpoint=endpoint, + # + windowing=windowing, + ) + + # --- the three-namespace merge (D27) ------------------------------------- # + + async def list_endpoints(self, *, scope: AuthScope) -> List[MCPEndpoint]: + """Takes the scope rather than a bare project_id (R14). §8 derives + GatewayConnectionState "per owner and per namespace", which a project_id alone + cannot express; `_connection_state` is the seam that wiring lands on.""" + project_id = scope.project_id + custom = await self.mcp_endpoints_dao.query_endpoints(project_id=project_id) + + # is_active=None, like _resolve_target: D18 keeps a revoked connection's tools + # listed, in NEEDS_AUTH, rather than making the endpoint disappear. + connections = await self.connections_service.query_connections( + project_id=project_id, + provider_key="composio", + is_active=None, + ) + builtin = [self._builtin_endpoint(connection) for connection in connections] + + # builtin's two providers first (agenta, then composio), custom rows last — no + # ordering guarantee is promised by entities.md §8. + return [*self._agenta_endpoints(), *builtin, *custom] + + def _agenta_endpoints(self) -> List[MCPEndpoint]: + """The endpoints Agenta itself serves — the `agenta` provider inside `builtin` + (D23, D30). Private and + service-internal — entities.md names no public symbol for this, unlike the LLM + plane's `standard_llm_endpoint(s)`. Wave 1's only member is WP5's deployable + mock MCP server; slug "tools" matches the route grammar's own worked example + (D30: `/gateways/mcps/builtin/agenta/{slug}` -> `builtin/agenta/tools`).""" + return [ + MCPEndpoint( + slug="tools", + name="Agenta Tools", + auth_mode=MCPAuthScheme.NONE, + namespace=GatewayEndpointNamespace.BUILTIN, + provider_key=AGENTA_PROVIDER, + data=MCPEndpointData( + route=MCPEndpointRoute(base_url=env.mock_gateways.mcp_url) + ), + ) + ] + + def _builtin_endpoint(self, connection: Connection) -> MCPEndpoint: + """One generated (never persisted, D19/D20) `MCPEndpoint` per active composio + `Connection`. `data.url` is a non-dialable placeholder: no document in this + design fixes a real Composio MCP base URL, and D23 keeps every `builtin` MCP + target unreachable this wave (`ComposioMCPAdapter` has no owner) — a + placeholder keeps the required field populated without inventing an endpoint + nobody owns yet.""" + return MCPEndpoint( + slug=connection.slug, + name=connection.name, + namespace=GatewayEndpointNamespace.BUILTIN, + connection_id=connection.id, + provider_key=connection.provider_key.value, + integration_key=connection.integration_key, + auth_mode=( + MCPAuthScheme.NONE if not connection.has_auth else MCPAuthScheme.OAUTH + ), + data=MCPEndpointData( + route=MCPEndpointRoute( + base_url=_builtin_placeholder_url( + provider=connection.provider_key.value, + integration=connection.integration_key, + slug=connection.slug, + ) + ) + ), + ) + + # --- connection-state derivation (entities.md §8, "Where the state machine is + # computed") ---------------------------------------------------------------- # + + async def _connection_state( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + # + endpoint: MCPEndpoint, + ) -> GatewayConnectionState: + """Per owner, per namespace (§8, verbatim in specs-wp9.md). Not called by + `list_endpoints` in wave 1 — that method takes no owner, so it cannot derive a + per-caller state — this is exercised directly by its own unit tests and is the + seam a future per-owner read (the CRUD router, or the connect-affordance + builder, D17) calls into. Nothing here is stored (§2.6): every call recomputes.""" + if endpoint.auth_mode == MCPAuthScheme.NONE: + return GatewayConnectionState.READY + + if endpoint.namespace == GatewayEndpointNamespace.CUSTOM: + if endpoint.secret_id is not None and endpoint.flags.is_valid: + return GatewayConnectionState.READY + return GatewayConnectionState.NEEDS_AUTH + + if endpoint.connection_id is not None: + connection = await self.connections_service.get_connection( + project_id=project_id, + connection_id=endpoint.connection_id, + ) + if connection is not None and connection.is_active and connection.is_valid: + return GatewayConnectionState.READY + return GatewayConnectionState.NEEDS_AUTH + + # NEEDS_INPUT is reserved for the api_key scheme, deferred with its kind (D14); + # unreachable today because no custom endpoint can carry auth_mode=API_KEY yet. + return GatewayConnectionState.NEEDS_AUTH + + # --- the data plane (WP8 calls this) --------------------------------------- # + + async def relay( + self, + *, + scope: AuthScope, + namespace: GatewayEndpointNamespace, + name: str, + provider: Optional[str] = None, + integration: Optional[str] = None, + # + context: MCPCallContext, + body: bytes, + headers: Dict[str, str], + ) -> MCPRelayResult: + """The six-step orchestration (§8, D7 applied to MCP).""" + + # `_ResolvedTarget` is a plain dataclass, not a pydantic model, so a caller + # passing the namespace as a bare string (the FastAPI path-param case, or a + # test) is not auto-coerced the way GatewayTarget's own field would be — + # every downstream `.value` access (MCPEndpointNotFoundError, _adapter_key) + # needs a real enum member. + namespace = GatewayEndpointNamespace(namespace) + + # 1. Resolve target. + target = await self._resolve_target( + project_id=scope.project_id, + namespace=namespace, + name=name, + provider=provider, + integration=integration, + ) + + self._check_active(target) + + # 2. Allowlist before secret — a refused tool must not cost a vault read. + self._check_allowlist(target, context) + + policy_target = GatewayTarget( + plane=GatewayPlane.MCP, + namespace=namespace, + name=name, + provider=provider, + integration=integration, + endpoint_id=target.endpoint.id, + method=context.method, + tool=context.target, + ) + + # 3. Authorize. The denial is recorded before the exception leaves. + decision = await self.policy.authorize( + scope=scope, + permission=Permission.USE_MCP_ENDPOINTS, + target=policy_target, + ) + if not decision.allowed: + await self.policy.record( + scope=scope, + target=policy_target, + decision=decision, + outcome=GatewayOutcome(status_code=403), + ) + raise PolicyDeniedError( + permission=Permission.USE_MCP_ENDPOINTS, + target=_target_path( + namespace=namespace, + provider=provider, + integration=integration, + name=name, + ), + ) + + # 4. Resolve secret — the two-mechanism fork (D27, §4.4). + auth = await self._resolve_auth(scope=scope, target=target) + + # 5. Dispatch. Usage is recorded even on failure. + route = self._route_for(target) + adapter_key = _adapter_key(namespace=namespace, provider=provider) + try: + result = await self.upstream_registry.get(adapter_key).relay( + route=route, + auth=auth, + context=context, + body=body, + headers=headers, + ) + except MCPUpstreamError as exc: + await self.policy.record( + scope=scope, + target=policy_target, + decision=decision, + outcome=GatewayOutcome(status_code=exc.status_code), + ) + raise + + # 5b. Step-up (D17, WP19): a `custom` OAuth target's upstream answering 403 with + # an RFC 6750 `insufficient_scope` challenge raises an interaction instead of + # passing the refusal through — same treatment `_resolve_auth` already gives a + # missing secret, one step later (after dial, since the challenge is the + # upstream's to raise, not ours to predict). + if ( + namespace == GatewayEndpointNamespace.CUSTOM + and target.endpoint.auth_mode == MCPAuthScheme.OAUTH + and result.status_code == 403 + ): + challenged_scopes = _parse_scope_challenge(result.headers) + if challenged_scopes is not None: + await self.policy.record( + scope=scope, + target=policy_target, + decision=decision, + outcome=GatewayOutcome(status_code=403), + ) + raise MCPScopeInsufficientError( + target=_target_path( + namespace=namespace, + provider=provider, + integration=integration, + name=name, + ), + scopes=challenged_scopes, + endpoint_id=target.endpoint.id, + ) + + # 6. Record, then — for a list method — filter the response body. + await self.policy.record( + scope=scope, + target=policy_target, + decision=decision, + outcome=self._outcome_for(result=result, auth=auth), + ) + + if context.method == "tools/list": + result = _filter_tool_list(result=result, tools=target.endpoint.data.tools) + + return result + + # --- relay step helpers ----------------------------------------------------- # + + async def _resolve_target( + self, + *, + project_id: UUID, + namespace: GatewayEndpointNamespace, + name: str, + provider: Optional[str], + integration: Optional[str], + ) -> _ResolvedTarget: + if ( + namespace == GatewayEndpointNamespace.BUILTIN + and provider == AGENTA_PROVIDER + ): + endpoint = next( + (e for e in self._agenta_endpoints() if e.slug == name), None + ) + if endpoint is None: + raise MCPEndpointNotFoundError(namespace=namespace, name=name) + return _ResolvedTarget( + namespace=namespace, name=name, provider=provider, endpoint=endpoint + ) + + if namespace == GatewayEndpointNamespace.BUILTIN: + connections = await self.connections_service.query_connections( + project_id=project_id, + provider_key=provider, + integration_key=integration, + is_active=None, + ) + connection = next((c for c in connections if c.slug == name), None) + if connection is None: + raise MCPEndpointNotFoundError( + namespace=namespace, + provider=provider, + integration=integration, + name=name, + ) + return _ResolvedTarget( + namespace=namespace, + name=name, + provider=provider, + integration=integration, + endpoint=self._builtin_endpoint(connection), + connection=connection, + ) + + # CUSTOM + endpoint = await self.mcp_endpoints_dao.fetch_endpoint_by_slug( + project_id=project_id, slug=name + ) + if endpoint is None: + raise MCPEndpointNotFoundError(namespace=namespace, name=name) + return _ResolvedTarget(namespace=namespace, name=name, endpoint=endpoint) + + @staticmethod + def _check_active(target: _ResolvedTarget) -> None: + if not target.endpoint.flags.is_active: + raise GatewayEndpointInactiveError( + target=_target_path( + namespace=target.namespace, + provider=target.provider, + integration=target.integration, + name=target.name, + ) + ) + + def _check_allowlist( + self, target: _ResolvedTarget, context: MCPCallContext + ) -> None: + tool = context.target + if tool is None: + return + + if not target.endpoint.data.tools.allows(tool): + raise MCPToolNotAllowedError( + tool=tool, + namespace=target.namespace, + name=target.name, + provider=target.provider, + integration=target.integration, + ) + + async def _resolve_auth( + self, *, scope: AuthScope, target: _ResolvedTarget + ) -> MCPRelayAuth: + if target.provider == COMPOSIO_PROVIDER: + # the broker's secret never enters our vault + # (§4.4) — never routed through the resolver. + connection = target.connection + if connection is None or not (connection.is_active and connection.is_valid): + # §1: secret death refuses the call, it never hides the configuration. + raise SecretInvalidError( + target=_target_path( + namespace=target.namespace, + provider=target.provider, + integration=target.integration, + name=target.name, + ) + ) + return MCPBrokeredAuth(connection=connection) + + endpoint = target.endpoint + if endpoint.auth_mode == MCPAuthScheme.NONE: + return MCPDirectAuth(secret=None) + + if endpoint.auth_mode == MCPAuthScheme.OAUTH: + if endpoint.secret_id is None: + raise SecretNotFoundError( + missing=SecretOwnerKind.PROJECT, + target=_target_path( + namespace=target.namespace, + provider=target.provider, + integration=target.integration, + name=target.name, + ), + mode=SecretMode.PROJECT_ONLY, + ) + secret = await self.resolver.resolve( + scope=scope, + ref=BoundSecretRef(secret_id=endpoint.secret_id), + mode=SecretMode.PROJECT_ONLY, # one consent per server (out-of-scope.md) + ) + return MCPDirectAuth(secret=secret) + + # API_KEY: no static MCP secret kind exists yet (D14) — deferred with its kind. + raise NotImplementedError("api_key scheme MCP endpoints are deferred (D14)") + + def _route_for(self, target: _ResolvedTarget) -> MCPResolvedRoute: + if target.provider == COMPOSIO_PROVIDER: + return MCPResolvedRoute( + url=_builtin_placeholder_url( + provider=target.provider or "", + integration=target.integration or "", + slug=target.name, + ) + ) + endpoint = target.endpoint + return MCPResolvedRoute( + url=endpoint.data.route.base_url or "", + headers=endpoint.data.route.headers or {}, + settings=endpoint.data.settings, + ) + + def _outcome_for( + self, *, result: MCPRelayResult, auth: MCPRelayAuth + ) -> GatewayOutcome: + if isinstance(auth, MCPDirectAuth) and auth.secret is not None: + return GatewayOutcome( + status_code=result.status_code, + owner=auth.secret.owner, + origin=auth.secret.origin, + ) + # No secret resolved (NONE-scheme), or a MCPBrokeredAuth connection — + # neither came from our resolver/vault, so owner/origin stay unset (§2.7: + # "None when no secret was resolved"). + return GatewayOutcome(status_code=result.status_code) + + +def _filter_tool_list( + *, result: MCPRelayResult, tools: MCPToolFilter +) -> MCPRelayResult: + """Step 6's one body rewrite (§8): the filter drops whole tool entries, never + renames a surviving one; an unconstrained filter passes the response through + untouched. Scoped strictly to `tools/list`'s own JSON-RPC shape — never applied + to resources/list or prompts/list, whose entries a tool filter says nothing + about.""" + if tools.allowlist is None and tools.denylist is None: + return result + + try: + payload = json.loads(result.body) if result.body else None + except (json.JSONDecodeError, TypeError): + return result + + if not isinstance(payload, dict): + return result + listed = (payload.get("result") or {}).get("tools") + if not isinstance(listed, list): + return result + + payload["result"]["tools"] = [ + entry + for entry in listed + if isinstance(entry, dict) and tools.allows(str(entry.get("name"))) + ] + + return MCPRelayResult( + status_code=result.status_code, + headers=result.headers, + body=json.dumps(payload).encode(), + ) + + +def _builtin_placeholder_url(*, provider: str, integration: str, slug: str) -> str: + return f"composio://{provider}/{integration}/{slug}" diff --git a/api/oss/src/core/gateways/mcps/types.py b/api/oss/src/core/gateways/mcps/types.py new file mode 100644 index 0000000000..48e4ddc731 --- /dev/null +++ b/api/oss/src/core/gateways/mcps/types.py @@ -0,0 +1,97 @@ +"""MCP plane domain exceptions (entities.md §5).""" + +from typing import List, Optional +from uuid import UUID + +from oss.src.core.gateways.dtos import ( + GatewayConnectionRequirement, + GatewayEndpointNamespace, +) +from oss.src.core.gateways.types import GatewaysError + + +class MCPEndpointNotFoundError(GatewaysError): + def __init__( + self, + *, + namespace: GatewayEndpointNamespace, + name: str, + provider: Optional[str] = None, + integration: Optional[str] = None, + ): + self.namespace = namespace + self.provider = provider + self.integration = integration + self.name = name + target = "/".join( + s for s in (namespace.value, provider, integration, name) if s + ) + super().__init__(f"MCP endpoint not found: {target}") + + +class MCPToolNotAllowedError(GatewaysError): + """The named tool is outside the endpoint's tool policy (§2.4).""" + + def __init__( + self, + *, + tool: str, + namespace: GatewayEndpointNamespace, + name: str, + provider: Optional[str] = None, + integration: Optional[str] = None, + ): + self.tool = tool + self.namespace = namespace + self.provider = provider + self.integration = integration + self.name = name + target = "/".join( + s for s in (namespace.value, provider, integration, name) if s + ) + super().__init__(f"Tool {tool} not allowed on {target}") + + +class MCPAuthRequiredError(GatewaysError): + """No usable grant for this owner on an OAuth endpoint. Carries the + requirement so the boundary can return the connect affordance instead of a + bare failure (D17).""" + + def __init__(self, *, requirement: GatewayConnectionRequirement): + self.requirement = requirement + super().__init__(f"Authorization required for {requirement.target}") + + +class MCPScopeInsufficientError(GatewaysError): + """A step-up scope challenge from the upstream (D17; `mcp.md`; WP19). Raised by + `MCPGatewayService.relay` when a `custom` OAuth endpoint's upstream answers 403 + with an RFC 6750 `insufficient_scope` challenge. `endpoint_id` is optional so the + boundary can attach a connect affordance without widening every existing caller + (specs-wp17.md/wp18.md's own precedent — WP17's tests construct this with only + `target`/`scopes`).""" + + def __init__( + self, + *, + target: str, + scopes: List[str], + endpoint_id: Optional[UUID] = None, + ): + self.target = target + self.scopes = scopes + self.endpoint_id = endpoint_id + super().__init__(f"Additional scopes required for {target}: {scopes}") + + +class MCPUpstreamError(GatewaysError): + def __init__( + self, + *, + target: str, + status_code: Optional[int] = None, + detail: Optional[str] = None, + ): + self.target = target + self.status_code = status_code + self.detail = detail + super().__init__(f"Upstream {target} failed ({status_code})") diff --git a/api/oss/src/core/gateways/policy/__init__.py b/api/oss/src/core/gateways/policy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/core/gateways/policy/audit.py b/api/oss/src/core/gateways/policy/audit.py new file mode 100644 index 0000000000..7766ae3abc --- /dev/null +++ b/api/oss/src/core/gateways/policy/audit.py @@ -0,0 +1,82 @@ +"""Gateway call audit events (D22, specs-wp4.md). + +`build_gateway_call_attributes` / `publish_gateway_call` follow the shape of +`build_trace_fetched_attributes` / `publish_trace_fetched` +(`core/events/utils.py`) — one flat-attribute builder, one publisher, no new +event pipeline. Every gateway relay's `record()` call, allow or deny, on +either plane, lands here. +""" + +from typing import Any, Dict + +from oss.src.core.events.types import EventType +from oss.src.core.events.utils import _build_event, _safe_publish +from oss.src.core.gateways.policy.dtos import ( + GatewayOutcome, + GatewayTarget, + PolicyDecision, +) +from oss.src.utils.context import AuthScope +from oss.src.utils.logging import get_module_logger + +log = get_module_logger(__name__) + + +def build_gateway_call_attributes( + *, + scope: AuthScope, + target: GatewayTarget, + decision: PolicyDecision, + outcome: GatewayOutcome, +) -> Dict[str, Any]: + """Flat attribute map for one gateway call — the audit record, never the + request or response body: no prompt, no completion, no secret value, no + header (specs-wp4.md).""" + attributes: Dict[str, Any] = { + "organization_id": str(scope.organization_id), + "workspace_id": str(scope.workspace_id), + "project_id": str(scope.project_id), + "user_id": str(scope.user_id), + "plane": target.plane.value, + "namespace": target.namespace.value, + "name": target.name, + "allowed": decision.allowed, + } + if target.endpoint_id is not None: + attributes["endpoint_id"] = str(target.endpoint_id) + if target.model is not None: + attributes["model"] = target.model + if decision.reason is not None: + attributes["reason"] = decision.reason + if outcome.status_code is not None: + attributes["status_code"] = outcome.status_code + if outcome.origin is not None: + # The spend-attribution field: unset means the caller's own + # pass-through secret paid, not ours. + attributes["secret_origin"] = outcome.origin.value + return attributes + + +async def publish_gateway_call( + *, + scope: AuthScope, + target: GatewayTarget, + decision: PolicyDecision, + outcome: GatewayOutcome, +) -> None: + """One event per call. Never raises: `record()` runs on the deny path, + where an exception would turn a clean 403 into a 500.""" + try: + attributes = build_gateway_call_attributes( + scope=scope, target=target, decision=decision, outcome=outcome + ) + await _safe_publish( + organization_id=scope.organization_id, + project_id=scope.project_id, + event=_build_event( + event_type=EventType.GATEWAYS_CALLED, + attributes=attributes, + ), + ) + except Exception: # noqa: BLE001 - audit must never affect the relay's result + log.warning("[gateways] failed to publish audit event", exc_info=True) diff --git a/api/oss/src/core/gateways/policy/dtos.py b/api/oss/src/core/gateways/policy/dtos.py new file mode 100644 index 0000000000..fd14179cc3 --- /dev/null +++ b/api/oss/src/core/gateways/policy/dtos.py @@ -0,0 +1,125 @@ +"""The policy core's DTOs (entities.md §4.2). + +Principal-adjacent shapes shared by both planes: what the resolver is asked for, what +policy decides, and what audit records. +""" + +from enum import Enum +from typing import Optional, Union +from uuid import UUID + +from pydantic import BaseModel + +from oss.src.core.access.permissions.types import Permission +from oss.src.core.gateways.dtos import GatewayEndpointNamespace +from oss.src.core.secrets.dtos import SecretResponseDTO + + +class GatewayPlane(str, Enum): + LLM = "llm" + MCP = "mcp" + + +class SecretMode(str, Enum): + """Declared per resolution site, not per call (`secrets.md`).""" + + USER_OPTIONAL = "user_optional" # the user's if present, else the project's + USER_REQUIRED = "user_required" # the user's, or fail — never fall back + PROJECT_ONLY = "project_only" # always the project's; ignore user secrets + + +class SecretOwnerKind(str, Enum): + PROJECT = "project" + USER = "user" + + +class SecretOwner(BaseModel): + """Whose stored secret answered the lookup. Audit cannot reconstruct this + later, which is why it travels with the secret (`secrets.md`).""" + + kind: SecretOwnerKind + user_id: Optional[UUID] = None # set exactly when kind is USER + + +class SecretOrigin(str, Enum): + """Whose money the call spends — the payer. `vault` is the customer's own + secret; `local` is platform-funded. Vocabulary fixed by `secrets.md`; + coordinate values with the parallel bring-your-own-secrets work, which uses + the same axis to zero-rate customer-funded usage.""" + + VAULT = "vault" + LOCAL = "local" + + +# --- what the resolver is asked for ---------------------------------------- # + + +class ProviderKeyRef(BaseModel): + """A builtin LLM endpoint — the standard-provider set (D27): find the + provider_key secret for this provider.""" + + provider_key: str + + +class BoundSecretRef(BaseModel): + """A custom endpoint: the row already names its secret (§2.1).""" + + secret_id: UUID + + +SecretRef = Union[ProviderKeyRef, BoundSecretRef] + + +class ResolvedSecret(BaseModel): + """The (secret, owner, payer) triple (`secrets.md`). Never serialized + outward: it exists between the resolver and an adapter, in process, and no + wire model embeds it.""" + + secret: SecretResponseDTO # decrypted, from VaultService + owner: SecretOwner + origin: SecretOrigin + + +# --- what policy decides, and what audit records ---------------------------- # + + +class GatewayTarget(BaseModel): + """The plane-neutral description of what a call is trying to reach.""" + + plane: GatewayPlane + namespace: GatewayEndpointNamespace + name: str # last path component: a slug, a provider key, or a connection slug + # + provider: Optional[str] = None # MCP builtin: the broker segment (D27) + integration: Optional[str] = None # MCP builtin: the integration segment (D27) + endpoint_id: Optional[UUID] = None # set when the target is a row + model: Optional[str] = None # LLM plane + method: Optional[str] = None # MCP plane: the protocol method + tool: Optional[str] = None # MCP plane: the target tool, when one is named + + +class PolicyDecision(BaseModel): + allowed: bool + permission: Permission # the subject that was checked (§9) + reason: Optional[str] = None # denial cause, stable and terse; None when allowed + + +class GatewayUsage(BaseModel): + """What the meter needs, plane-neutral. Tokens on the LLM plane, calls on + both; recorded from day one even while nothing is charged (`policy.md`).""" + + calls: int = 1 + input_tokens: Optional[int] = None + output_tokens: Optional[int] = None + cost: Optional[float] = None + + +class GatewayOutcome(BaseModel): + """How the call ended, for the audit event (§2.7).""" + + status_code: Optional[int] = None + duration_ms: Optional[int] = None + # + usage: Optional[GatewayUsage] = None + owner: Optional[SecretOwner] = None # None when no secret was resolved + origin: Optional[SecretOrigin] = None diff --git a/api/oss/src/core/gateways/policy/interfaces.py b/api/oss/src/core/gateways/policy/interfaces.py new file mode 100644 index 0000000000..1bcf4d6a22 --- /dev/null +++ b/api/oss/src/core/gateways/policy/interfaces.py @@ -0,0 +1,71 @@ +"""The secret resolver port (entities.md §7.2). + +Implemented by `policy/resolution.py` over `VaultService` and the grants DAO (WP2). This +is the signature the seed must get right (D10): the owner is in it from the first commit, +while the only answer today is the project. +""" + +from abc import ABC, abstractmethod +from typing import Set + +from oss.src.core.gateways.policy.dtos import ( + SecretMode, + SecretRef, + ResolvedSecret, +) +from oss.src.utils.context import AuthScope + + +class SecretsResolverInterface(ABC): + """One lookup, called by both planes (`secrets.md`). 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. + + User-owned secrets are out of scope (`out-of-scope.md`), so the user arm + of every mode finds nothing and the modes degrade to project lookup or + failure. The signature keeps the owner anyway, per D10: reopening this is + then a new table and a new arm, never a signature change. + + 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. + Both planes' endpoints name their secret this way, + OAuth included; SecretInvalidError when the + endpoint's is_valid is False (D18). + + Raises, never returns None: no path silently yields "no secret" + (`secrets.md`), and the exceptions carry which owner is missing so the + boundary can build the connect affordance (§5).""" + raise NotImplementedError + + @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. + + Same scan as the ProviderKeyRef arm (provider_key + custom_provider), + returning the provider names found. Unlike resolve() it does NOT raise + when nothing matches: the empty set is the correct answer for a project + with no keys, whereas a caller reaching resolve() has already committed + to needing one.""" + raise NotImplementedError diff --git a/api/oss/src/core/gateways/policy/resolution.py b/api/oss/src/core/gateways/policy/resolution.py new file mode 100644 index 0000000000..1788a07c5c --- /dev/null +++ b/api/oss/src/core/gateways/policy/resolution.py @@ -0,0 +1,146 @@ +"""`SecretsResolver` — the one lookup both gateways call to turn a `SecretRef` +into a `(secret, owner, payer)` triple (`entities.md` §7.2, WP2). + +Pure orchestration over `VaultService`; this module never talks to Postgres or the +vault's encryption directly. +""" + +from typing import Optional, Set + +from oss.src.core.gateways.policy.dtos import ( + BoundSecretRef, + SecretMode, + SecretOwner, + SecretOwnerKind, + SecretRef, + ProviderKeyRef, + ResolvedSecret, + SecretOrigin, +) +from oss.src.core.gateways.policy.interfaces import SecretsResolverInterface +from oss.src.core.gateways.policy.types import ( + SecretNotFoundError, +) +from oss.src.core.secrets.dtos import SecretResponseDTO +from oss.src.core.secrets.enums import SecretKind +from oss.src.core.secrets.services import VaultService +from oss.src.utils.context import AuthScope + + +class SecretsResolver(SecretsResolverInterface): + """`VaultService`, wrapped (D23: mockable).""" + + def __init__( + self, + *, + vault_service: VaultService, + ) -> None: + self.vault_service = vault_service + + async def resolve( + self, + *, + scope: AuthScope, + # + ref: SecretRef, + mode: SecretMode, + ) -> ResolvedSecret: + if isinstance(ref, BoundSecretRef): + return await self._resolve_bound_secret(scope=scope, ref=ref, mode=mode) + if isinstance(ref, ProviderKeyRef): + return await self._resolve_provider_key(scope=scope, ref=ref, mode=mode) + raise TypeError(f"Unsupported SecretRef type: {type(ref)!r}") + + async def available_provider_keys(self, *, scope: AuthScope) -> Set[str]: + secrets = await self.vault_service.list_secrets(project_id=scope.project_id) + return { + secret.data.kind.value + for secret in secrets + if secret.kind in (SecretKind.PROVIDER_KEY, SecretKind.CUSTOM_PROVIDER) + } + + # --- BoundSecretRef -------------------------------------------------------- # + + async def _fetch_bound_secret( + self, *, scope: AuthScope, ref: BoundSecretRef + ) -> Optional[SecretResponseDTO]: + return await self.vault_service.get_secret_by_id( + ref.secret_id, project_id=scope.project_id + ) + + async def _resolve_bound_secret( + self, *, scope: AuthScope, ref: BoundSecretRef, mode: SecretMode + ) -> ResolvedSecret: + target = f"secret:{ref.secret_id}" + + # No owner column exists on a bound secret today (secrets.md): every mode + # degrades to the same project-only lookup until user-owned secrets ship. + # The branch stays explicit so only the per-mode lookup changes later. + if mode == SecretMode.PROJECT_ONLY: + secret = await self._fetch_bound_secret(scope=scope, ref=ref) + elif mode == SecretMode.USER_REQUIRED: + secret = await self._fetch_bound_secret(scope=scope, ref=ref) + elif mode == SecretMode.USER_OPTIONAL: + secret = await self._fetch_bound_secret(scope=scope, ref=ref) + else: + raise TypeError(f"Unsupported SecretMode: {mode!r}") + + if secret is None: + raise SecretNotFoundError( + mode=mode, missing=SecretOwnerKind.PROJECT, target=target + ) + return ResolvedSecret( + secret=secret, + owner=SecretOwner(kind=SecretOwnerKind.PROJECT), + origin=SecretOrigin.VAULT, + ) + + # --- ProviderKeyRef --------------------------------------------------------- # + + async def _match_provider_secret( + self, *, scope: AuthScope, ref: ProviderKeyRef + ) -> Optional[SecretResponseDTO]: + secrets = await self.vault_service.list_secrets(project_id=scope.project_id) + return next( + ( + secret + for secret in secrets + if secret.kind == SecretKind.PROVIDER_KEY + and secret.data.kind == ref.provider_key + ), + None, + ) or next( + ( + secret + for secret in secrets + if secret.kind == SecretKind.CUSTOM_PROVIDER + and secret.data.kind == ref.provider_key + ), + None, + ) + + async def _resolve_provider_key( + self, *, scope: AuthScope, ref: ProviderKeyRef, mode: SecretMode + ) -> ResolvedSecret: + target = f"provider:{ref.provider_key}" + + # Same degenerate-today, explicit-forever branch as BoundSecretRef: no + # (project, user) provider-key lookup exists yet. + if mode == SecretMode.PROJECT_ONLY: + match = await self._match_provider_secret(scope=scope, ref=ref) + elif mode == SecretMode.USER_REQUIRED: + match = await self._match_provider_secret(scope=scope, ref=ref) + elif mode == SecretMode.USER_OPTIONAL: + match = await self._match_provider_secret(scope=scope, ref=ref) + else: + raise TypeError(f"Unsupported SecretMode: {mode!r}") + + if match is None: + raise SecretNotFoundError( + mode=mode, missing=SecretOwnerKind.PROJECT, target=target + ) + return ResolvedSecret( + secret=match, + owner=SecretOwner(kind=SecretOwnerKind.PROJECT), + origin=SecretOrigin.VAULT, + ) diff --git a/api/oss/src/core/gateways/policy/service.py b/api/oss/src/core/gateways/policy/service.py new file mode 100644 index 0000000000..1e08fdbe18 --- /dev/null +++ b/api/oss/src/core/gateways/policy/service.py @@ -0,0 +1,89 @@ +"""GatewayPolicyService: authorize, audit, usage (entities.md §8, WP3/WP4). + +`authorize()` is the whole of wave 1's decision. There is no entitlement arm (D29): +every user has both gateways, so a check here would ask a question with one answer — +what entitlements will express here are limits, which cannot be enforced before +anything is measured, and ship with metering and billing. `record()` builds and +publishes the audit event via WP4's `policy/audit.py`. +""" + +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.policy.audit import publish_gateway_call +from oss.src.core.gateways.policy.dtos import ( + GatewayOutcome, + GatewayTarget, + PolicyDecision, +) +from oss.src.core.gateways.policy.interfaces import SecretsResolverInterface +from oss.src.utils.context import AuthScope +from oss.src.utils.logging import get_module_logger + +log = get_module_logger(__name__) + + +class GatewayPolicyService: + def __init__( + self, + *, + resolver: SecretsResolverInterface, + ) -> None: + self.resolver = resolver + + # --- authorization (WP3) ------------------------------------------------ # + + async def authorize( + self, + *, + scope: AuthScope, + permission: Permission, + target: GatewayTarget, + ) -> PolicyDecision: + # Fails closed, and returns rather than raising (entities.md §8): an RBAC + # dependency blip is a denial the caller can audit, not a 500 that skips + # record() and loses the event. + try: + allowed = await check_action_access( + user_uid=str(scope.user_id), + project_id=str(scope.project_id), + permission=permission, + ) + except Exception: # noqa: BLE001 - any failure denies; never opens + log.error( + "[gateways] authorization check failed; denying", + permission=permission.value, + project_id=str(scope.project_id), + exc_info=True, + ) + return PolicyDecision( + allowed=False, + permission=permission, + reason="permission_check_failed", + ) + if not allowed: + return PolicyDecision( + allowed=False, + permission=permission, + reason="permission_denied", + ) + + return PolicyDecision(allowed=True, permission=permission, reason=None) + + # --- audit + usage (WP4, D22, §2.7) ------------------------------------- # + + async def record( + self, + *, + scope: AuthScope, + target: GatewayTarget, + decision: PolicyDecision, + outcome: GatewayOutcome, + ) -> None: + # Every relay in WP6/7/8/9 calls this on both the allow and deny + # branch; publish_gateway_call never raises (D22, specs-wp4.md). + await publish_gateway_call( + scope=scope, + target=target, + decision=decision, + outcome=outcome, + ) diff --git a/api/oss/src/core/gateways/policy/types.py b/api/oss/src/core/gateways/policy/types.py new file mode 100644 index 0000000000..ea4a309381 --- /dev/null +++ b/api/oss/src/core/gateways/policy/types.py @@ -0,0 +1,79 @@ +"""Policy + resolution exceptions (entities.md §5). + +`PolicyDeniedError` and `SecretNotFoundError` are different failures on purpose: the +first says you may not, the second says you could, once someone connects — the second maps +to the needs-auth / needs-input interaction path (D17). +""" + +from typing import Optional, Union + +from oss.src.core.access.permissions.types import Permission +from oss.src.core.gateways.policy.dtos import SecretMode, SecretOwnerKind +from oss.src.core.gateways.types import GatewaysError + + +class PolicyDeniedError(GatewaysError): + """The permission check refused (WP3). Carries the subject and the target so + the denial is explainable on a fixed-shape wire (§9).""" + + def __init__(self, *, permission: Permission, target: str): + self.permission = permission + self.target = target + super().__init__(f"Denied {permission.value} on {target}") + + +class EntitlementDeniedError(GatewaysError): + """The plan-level check refused. Distinct from PolicyDeniedError because + permissions and entitlements answer different questions and conflating them + is a known trap (`policy.md`).""" + + def __init__(self, *, key: str, target: str): + self.key = key + self.target = target + super().__init__(f"Entitlement {key} exceeded for {target}") + + +class SecretNotFoundError(GatewaysError): + """Resolution failed. Names WHICH owner is missing a secret, so the + caller learns whether they must connect or an administrator must + (`secrets.md`: failure is never silent and never a fallback to none).""" + + def __init__(self, *, mode: SecretMode, missing: SecretOwnerKind, target: str): + self.mode = mode + self.missing = missing + self.target = target + super().__init__( + f"No {missing.value} secret for {target} under mode {mode.value}" + ) + + +class SecretInvalidError(GatewaysError): + """A secret exists and cannot be used — revoked, or refresh failed. + Surfaces as needs_auth with a connect affordance (D17, D18).""" + + def __init__(self, *, target: str, detail: Optional[str] = None): + self.target = target + self.detail = detail + super().__init__(f"Secret for {target} is invalid") + + +class CeilingExceededError(GatewaysError): + """A governance ceiling rejects; it never silently clamps (D25). Carries the + three facts the denial must name so a caller retries correctly the first + time: the ceiling, the value asked for, and the value allowed.""" + + def __init__( + self, + *, + ceiling: str, + requested: Union[int, float], + allowed: Union[int, float], + target: str, + ): + self.ceiling = ceiling # the config key, e.g. "max_output_tokens" + self.requested = requested + self.allowed = allowed + self.target = target + super().__init__( + f"{ceiling} on {target}: requested {requested}, allowed {allowed}" + ) diff --git a/api/oss/src/core/gateways/types.py b/api/oss/src/core/gateways/types.py new file mode 100644 index 0000000000..a4e1b0d2e9 --- /dev/null +++ b/api/oss/src/core/gateways/types.py @@ -0,0 +1,23 @@ +"""Domain exception base for the gateways (entities.md §5). + +One domain base so the router decorator can catch broadly; no HTTP status on any +exception — mapping happens at the boundary (`apis/fastapi/gateways/exceptions.py`). +""" + + +class GatewaysError(Exception): + """Base exception for the gateways domain.""" + + def __init__(self, message: str = "Gateways error"): + self.message = message + super().__init__(self.message) + + +class GatewayEndpointInactiveError(GatewaysError): + """The operator's switch is off (§2.6). One type for both planes: the flag, the + refusal and the reason are identical, only the endpoint named differs.""" + + def __init__(self, *, target: str): + self.target = target + self.flag = "is_active" + super().__init__(f"Endpoint is deactivated: {target}") diff --git a/api/oss/src/core/secrets/dtos.py b/api/oss/src/core/secrets/dtos.py index d7de1b31df..f85592b399 100644 --- a/api/oss/src/core/secrets/dtos.py +++ b/api/oss/src/core/secrets/dtos.py @@ -83,6 +83,30 @@ class CustomSecretDTO(BaseModel): secret: CustomSecretSettingsDTO +class OAuthProviderSettingsDTO(BaseModel): + client_id: str + client_secret: str + issuer_url: str + scopes: List[str] + extra: Dict[str, Any] = Field(default_factory=dict) + + +class OAuthProviderDTO(BaseModel): + provider: OAuthProviderSettingsDTO + + +class OAuthGrantSettingsDTO(BaseModel): + server: str + access_token: str + refresh_token: Optional[str] = None + expires_at: Optional[int] = None + scopes: List[str] + + +class OAuthGrantDTO(BaseModel): + grant: OAuthGrantSettingsDTO + + class SecretDTO(BaseModel): kind: SecretKind data: Union[ @@ -91,6 +115,8 @@ class SecretDTO(BaseModel): SSOProviderDTO, WebhookProviderDTO, CustomSecretDTO, + OAuthProviderDTO, + OAuthGrantDTO, ] @model_validator(mode="before") @@ -204,6 +230,39 @@ def validate_secret_data_based_on_kind(cls, values: Dict[str, Any]): ) else: raise ValueError("A custom_secret format must be 'text' or 'json'") + elif kind == SecretKind.OAUTH_PROVIDER.value: + if not isinstance(data, dict): + raise ValueError( + "The provided request secret dto is not a valid type for OAuthProviderDTO" + ) + provider = data.get("provider") + if not isinstance(provider, dict): + raise ValueError( + "The provided request secret dto is missing required fields for OAuthProviderSettingsDTO" + ) + required_fields = {"client_id", "client_secret", "issuer_url", "scopes"} + if not required_fields.issubset(provider.keys()): + raise ValueError( + "The provided request secret dto is missing required fields for OAuthProviderSettingsDTO" + ) + # OAuthProviderDTO and SSOProviderDTO share a shape; the kind must decide, not the union. + values["data"] = OAuthProviderDTO.model_validate(data) + elif kind == SecretKind.OAUTH_GRANT.value: + if not isinstance(data, dict): + raise ValueError( + "The provided request secret dto is not a valid type for OAuthGrantDTO" + ) + grant = data.get("grant") + if not isinstance(grant, dict): + raise ValueError( + "The provided request secret dto is missing required fields for OAuthGrantSettingsDTO" + ) + required_fields = {"server", "access_token", "scopes"} + if not required_fields.issubset(grant.keys()): + raise ValueError( + "The provided request secret dto is missing required fields for OAuthGrantSettingsDTO" + ) + values["data"] = OAuthGrantDTO.model_validate(data) else: raise ValueError("The provided kind is not a valid SecretKind enum") diff --git a/api/oss/src/core/secrets/enums.py b/api/oss/src/core/secrets/enums.py index f87ac12eac..32a65abb9c 100644 --- a/api/oss/src/core/secrets/enums.py +++ b/api/oss/src/core/secrets/enums.py @@ -7,6 +7,8 @@ class SecretKind(str, Enum): SSO_PROVIDER = "sso_provider" WEBHOOK_PROVIDER = "webhook_provider" CUSTOM_SECRET = "custom_secret" + OAUTH_PROVIDER = "oauth_provider" + OAUTH_GRANT = "oauth_grant" class CustomSecretFormat(str, Enum): diff --git a/api/oss/src/core/workflows/static_catalog.py b/api/oss/src/core/workflows/static_catalog.py index bdd10c423a..d2bf279aa7 100644 --- a/api/oss/src/core/workflows/static_catalog.py +++ b/api/oss/src/core/workflows/static_catalog.py @@ -114,7 +114,7 @@ def _skill_revision(skill_template: SkillTemplate) -> WorkflowRevision: def _client_tool_revision() -> WorkflowRevision: return WorkflowRevision( name=REQUEST_CONNECTION_WORKFLOW_NAME, - description="Ask the user to connect an external account.", + description="Ask the user to connect an external account or a gateway target.", data=WorkflowRevisionData( uri="client:tool:request_connection:v0", parameters={ @@ -124,25 +124,52 @@ def _client_tool_revision() -> WorkflowRevision: "tool": { "type": "client", "name": REQUEST_CONNECTION_TOOL_NAME, - "description": "Request a connection from the user.", + "description": ( + "Request a connection from the user: either an external integration " + "('integration') or a gateway target ('target') that a model or MCP " + "call was refused for because it is not registered yet. Provide " + "exactly one of 'integration' or 'target'." + ), "input_schema": { "type": "object", "properties": { "integration": { "type": "string", - "description": "The external integration key the user should connect, for example 'slack' or 'github'.", + "description": "The external integration key the user should connect, for example 'slack' or 'github'. Omit when connecting a gateway target instead (use 'target').", + }, + "target": { + "type": "object", + "description": ( + "A gateway target to connect instead of an external " + "integration: a model provider on the LLM plane, or a " + "server on the MCP plane. Use this after a model or tool " + "call was refused for an unregistered target." + ), + "properties": { + "plane": { + "type": "string", + "enum": ["llm", "mcp"], + "description": "Which gateway the target lives on.", + }, + "name": { + "type": "string", + "description": "The provider name (LLM plane, e.g. 'openai') or server slug (MCP plane, e.g. 'acme-notion') to connect.", + }, + }, + "required": ["plane", "name"], + "additionalProperties": False, }, "slug": { "type": "string", - "description": "Optional stable connection slug to create or reuse. Defaults to the integration key.", + "description": "Optional stable connection slug to create or reuse. Defaults to the integration key. Ignored for a gateway target.", }, "mode": { "type": "string", "enum": ["oauth", "api_key"], - "description": "Connection flow to request. Defaults to 'oauth'.", + "description": "Connection flow to request. Defaults to 'oauth'. Ignored for a gateway target.", }, }, - "required": ["integration"], + "required": [], "additionalProperties": False, }, "render": {"kind": "connect"}, diff --git a/api/oss/src/dbs/postgres/gateways/__init__.py b/api/oss/src/dbs/postgres/gateways/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/dbs/postgres/gateways/llms/__init__.py b/api/oss/src/dbs/postgres/gateways/llms/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/dbs/postgres/gateways/llms/dao.py b/api/oss/src/dbs/postgres/gateways/llms/dao.py new file mode 100644 index 0000000000..8bbfa581ce --- /dev/null +++ b/api/oss/src/dbs/postgres/gateways/llms/dao.py @@ -0,0 +1,228 @@ +from typing import List, Optional +from uuid import UUID + +from sqlalchemy import delete, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm.attributes import flag_modified + +from oss.src.core.gateways.llms.dtos import ( + LLMEndpoint, + LLMEndpointCreate, + LLMEndpointEdit, + LLMEndpointQuery, +) +from oss.src.core.gateways.llms.interfaces import LLMEndpointsDAOInterface +from oss.src.core.shared.dtos import Windowing +from oss.src.core.shared.exceptions import EntityCreationConflict +from oss.src.dbs.postgres.gateways.llms.dbes import LLMEndpointDBE +from oss.src.dbs.postgres.gateways.llms.mappings import ( + map_llm_endpoint_create_to_dbe, + map_llm_endpoint_dbe_to_dto, + map_llm_endpoint_edit_to_dbe, +) +from oss.src.dbs.postgres.shared.engine import ( + TransactionsEngine, + get_transactions_engine, +) +from oss.src.dbs.postgres.shared.utils import apply_windowing +from oss.src.utils.exceptions import suppress_exceptions +from oss.src.utils.logging import get_module_logger + +log = get_module_logger(__name__) + + +class LLMEndpointsDAO(LLMEndpointsDAOInterface): + def __init__( + self, + *, + LLMEndpointDBE: type = LLMEndpointDBE, + engine: TransactionsEngine = None, + ): + self.LLMEndpointDBE = LLMEndpointDBE + if engine is None: + engine = get_transactions_engine() + self.engine = engine + + @suppress_exceptions(exclude=[EntityCreationConflict]) + async def create_endpoint( + self, + *, + project_id: UUID, + user_id: UUID, + # + endpoint: LLMEndpointCreate, + ) -> Optional[LLMEndpoint]: + dbe = map_llm_endpoint_create_to_dbe( + project_id=project_id, + user_id=user_id, + # + dto=endpoint, + ) + + try: + async with self.engine.session() as session: + session.add(dbe) + await session.commit() + await session.refresh(dbe) + + return map_llm_endpoint_dbe_to_dto(dbe=dbe) + + except IntegrityError as e: + error_str = str(e.orig) if e.orig else str(e) + if "uq_llms_endpoints_project_slug" in error_str: + raise EntityCreationConflict( + entity="LLMEndpoint", + message=f"LLM endpoint with slug '{endpoint.slug}' already exists.", + conflict={"slug": endpoint.slug}, + ) from e + raise + + @suppress_exceptions(default=None) + async def fetch_endpoint( + self, + *, + project_id: UUID, + # + endpoint_id: UUID, + ) -> Optional[LLMEndpoint]: + async with self.engine.session() as session: + stmt = ( + select(self.LLMEndpointDBE) + .filter(self.LLMEndpointDBE.project_id == project_id) + .filter(self.LLMEndpointDBE.id == endpoint_id) + .limit(1) + ) + + result = await session.execute(stmt) + dbe = result.scalars().first() + + if not dbe: + return None + + return map_llm_endpoint_dbe_to_dto(dbe=dbe) + + @suppress_exceptions(default=None) + async def fetch_endpoint_by_slug( + self, + *, + project_id: UUID, + # + slug: str, + ) -> Optional[LLMEndpoint]: + async with self.engine.session() as session: + stmt = ( + select(self.LLMEndpointDBE) + .filter(self.LLMEndpointDBE.project_id == project_id) + .filter(self.LLMEndpointDBE.slug == slug) + .limit(1) + ) + + result = await session.execute(stmt) + dbe = result.scalars().first() + + if not dbe: + return None + + return map_llm_endpoint_dbe_to_dto(dbe=dbe) + + @suppress_exceptions(default=None) + async def edit_endpoint( + self, + *, + project_id: UUID, + user_id: UUID, + # + endpoint: LLMEndpointEdit, + ) -> Optional[LLMEndpoint]: + async with self.engine.session() as session: + stmt = ( + select(self.LLMEndpointDBE) + .filter(self.LLMEndpointDBE.project_id == project_id) + .filter(self.LLMEndpointDBE.id == endpoint.id) + .limit(1) + ) + + result = await session.execute(stmt) + dbe = result.scalars().first() + + if not dbe: + return None + + dbe = map_llm_endpoint_edit_to_dbe( + dbe=dbe, + user_id=user_id, + # + dto=endpoint, + ) + flag_modified(dbe, "data") + flag_modified(dbe, "flags") + + await session.commit() + await session.refresh(dbe) + + return map_llm_endpoint_dbe_to_dto(dbe=dbe) + + @suppress_exceptions(default=False) + async def delete_endpoint( + self, + *, + project_id: UUID, + # + endpoint_id: UUID, + ) -> bool: + async with self.engine.session() as session: + stmt = ( + delete(self.LLMEndpointDBE) + .where(self.LLMEndpointDBE.project_id == project_id) + .where(self.LLMEndpointDBE.id == endpoint_id) + ) + + result = await session.execute(stmt) + await session.commit() + + return result.rowcount > 0 + + @suppress_exceptions(default=[]) + async def query_endpoints( + self, + *, + project_id: UUID, + # + endpoint: Optional[LLMEndpointQuery] = None, + # + windowing: Optional[Windowing] = None, + ) -> List[LLMEndpoint]: + async with self.engine.session() as session: + stmt = select(self.LLMEndpointDBE).filter( + self.LLMEndpointDBE.project_id == project_id, + ) + + if endpoint: + if endpoint.provider_key is not None: + stmt = stmt.filter( + self.LLMEndpointDBE.provider_key == endpoint.provider_key + ) + + if endpoint.deployment_kind is not None: + stmt = stmt.filter( + self.LLMEndpointDBE.deployment_kind == endpoint.deployment_kind + ) + + if endpoint.slug is not None: + stmt = stmt.filter(self.LLMEndpointDBE.slug == endpoint.slug) + + if windowing: + stmt = apply_windowing( + stmt=stmt, + DBE=self.LLMEndpointDBE, + attribute="id", + order="descending", + windowing=windowing, + ) + else: + stmt = stmt.order_by(self.LLMEndpointDBE.created_at.desc()) + + result = await session.execute(stmt) + dbes = result.scalars().all() + + return [map_llm_endpoint_dbe_to_dto(dbe=dbe) for dbe in dbes] diff --git a/api/oss/src/dbs/postgres/gateways/llms/dbas.py b/api/oss/src/dbs/postgres/gateways/llms/dbas.py new file mode 100644 index 0000000000..48e2f3db0b --- /dev/null +++ b/api/oss/src/dbs/postgres/gateways/llms/dbas.py @@ -0,0 +1,45 @@ +"""LLM plane DBA mixins (entities.md §2).""" + +from sqlalchemy import UUID, Column +from sqlalchemy import Enum as SQLEnum +from sqlalchemy import String + +from oss.src.core.gateways.llms.dtos import LLMDeploymentKind +from oss.src.dbs.postgres.shared.dbas import ( + DataDBA, + FlagsDBA, + HeaderDBA, + IdentifierDBA, + LifecycleDBA, + MetaDBA, + ProjectScopeDBA, + SlugDBA, + StatusDBA, + TagsDBA, +) + + +class LLMEndpointDBA( + ProjectScopeDBA, + IdentifierDBA, + SlugDBA, + LifecycleDBA, + HeaderDBA, + DataDBA, + StatusDBA, + FlagsDBA, + TagsDBA, + MetaDBA, +): + """One custom LLM endpoint: a provider deployment_kind we reach (entities.md §2).""" + + __abstract__ = True + + # Nullable (entities.md §2.4): D34 removed the one branch that read it on a stored row + # (select_upstream's `direct` split), so it decides nothing and is a label only. + provider_key = Column(String, nullable=True) + deployment_kind = Column( + SQLEnum(LLMDeploymentKind, name="llmdeploymentkind_enum"), nullable=False + ) + secret_id = Column(UUID(as_uuid=True), nullable=True) + # data: { route, models, settings } — LLMEndpointData (entities.md §2.4) diff --git a/api/oss/src/dbs/postgres/gateways/llms/dbes.py b/api/oss/src/dbs/postgres/gateways/llms/dbes.py new file mode 100644 index 0000000000..8039c86533 --- /dev/null +++ b/api/oss/src/dbs/postgres/gateways/llms/dbes.py @@ -0,0 +1,36 @@ +"""LLM plane DBE (entities.md §3).""" + +from sqlalchemy import ( + ForeignKeyConstraint, + Index, + PrimaryKeyConstraint, + UniqueConstraint, +) + +from oss.src.dbs.postgres.gateways.llms.dbas import LLMEndpointDBA +from oss.src.dbs.postgres.shared.base import Base + + +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", + ), + ) diff --git a/api/oss/src/dbs/postgres/gateways/llms/mappings.py b/api/oss/src/dbs/postgres/gateways/llms/mappings.py new file mode 100644 index 0000000000..c9c75b587b --- /dev/null +++ b/api/oss/src/dbs/postgres/gateways/llms/mappings.py @@ -0,0 +1,98 @@ +"""LLM plane DBE <-> DTO mappings (entities.md §2, §4.3).""" + +from datetime import datetime, timezone +from uuid import UUID + +from oss.src.core.gateways.dtos import GatewayEndpointNamespace +from oss.src.core.gateways.llms.dtos import ( + LLMEndpoint, + LLMEndpointCreate, + LLMEndpointData, + LLMEndpointEdit, + LLMEndpointFlags, +) +from oss.src.core.shared.dtos import Status +from oss.src.dbs.postgres.gateways.llms.dbes import LLMEndpointDBE + + +def map_llm_endpoint_create_to_dbe( + *, + project_id: UUID, + user_id: UUID, + # + dto: LLMEndpointCreate, +) -> LLMEndpointDBE: + return LLMEndpointDBE( + project_id=project_id, + slug=dto.slug, + name=dto.name, + description=dto.description, + # + provider_key=dto.provider_key, + deployment_kind=dto.deployment_kind, + secret_id=dto.secret_id, + # + data=dto.data.model_dump(mode="json", exclude_none=True), + flags=dto.flags.model_dump(mode="json", exclude_none=True), + tags=dto.tags, + meta=dto.meta, + # + created_by_id=user_id, + ) + + +def map_llm_endpoint_dbe_to_dto( + *, + dbe: LLMEndpointDBE, +) -> LLMEndpoint: + data = LLMEndpointData(**(dbe.data or {})) + flags = LLMEndpointFlags(**(dbe.flags or {})) + status = Status(**dbe.status) if dbe.status else None + + return LLMEndpoint( + id=dbe.id, + slug=dbe.slug, + name=dbe.name, + description=dbe.description, + # + provider_key=dbe.provider_key, + deployment_kind=dbe.deployment_kind, + namespace=GatewayEndpointNamespace.CUSTOM, + secret_id=dbe.secret_id, + # + data=data, + flags=flags, + status=status, + tags=dbe.tags, + meta=dbe.meta, + # + created_at=dbe.created_at, + updated_at=dbe.updated_at, + deleted_at=dbe.deleted_at, + created_by_id=dbe.created_by_id, + updated_by_id=dbe.updated_by_id, + deleted_by_id=dbe.deleted_by_id, + ) + + +def map_llm_endpoint_edit_to_dbe( + *, + dbe: LLMEndpointDBE, + user_id: UUID, + # + dto: LLMEndpointEdit, +) -> LLMEndpointDBE: + """Full PUT over the editable surface (§4.3): data, flags, header, secret_id. + provider_key and deployment_kind are absent from LLMEndpointEdit and therefore + untouched here.""" + dbe.name = dto.name + dbe.description = dto.description + dbe.secret_id = dto.secret_id + dbe.data = dto.data.model_dump(mode="json", exclude_none=True) + dbe.flags = dto.flags.model_dump(mode="json", exclude_none=True) + dbe.tags = dto.tags + dbe.meta = dto.meta + dbe.updated_at = datetime.now(timezone.utc) + dbe.updated_by_id = user_id + + return dbe diff --git a/api/oss/src/dbs/postgres/gateways/mcps/__init__.py b/api/oss/src/dbs/postgres/gateways/mcps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/dbs/postgres/gateways/mcps/dao.py b/api/oss/src/dbs/postgres/gateways/mcps/dao.py new file mode 100644 index 0000000000..bcff945782 --- /dev/null +++ b/api/oss/src/dbs/postgres/gateways/mcps/dao.py @@ -0,0 +1,225 @@ +from typing import List, Optional +from uuid import UUID + +from sqlalchemy import delete, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm.attributes import flag_modified + +from oss.src.core.gateways.mcps.dtos import ( + MCPEndpoint, + MCPEndpointCreate, + MCPEndpointEdit, + MCPEndpointQuery, +) +from oss.src.core.gateways.mcps.interfaces import ( + MCPEndpointsDAOInterface, +) +from oss.src.core.shared.dtos import Windowing +from oss.src.core.shared.exceptions import EntityCreationConflict +from oss.src.dbs.postgres.gateways.mcps.dbes import MCPEndpointDBE +from oss.src.dbs.postgres.gateways.mcps.mappings import ( + map_mcp_endpoint_create_to_dbe, + map_mcp_endpoint_dbe_to_dto, + map_mcp_endpoint_edit_to_dbe, +) +from oss.src.dbs.postgres.shared.engine import ( + TransactionsEngine, + get_transactions_engine, +) +from oss.src.dbs.postgres.shared.utils import apply_windowing +from oss.src.utils.exceptions import suppress_exceptions +from oss.src.utils.logging import get_module_logger + +log = get_module_logger(__name__) + + +class MCPEndpointsDAO(MCPEndpointsDAOInterface): + def __init__( + self, + *, + MCPEndpointDBE: type = MCPEndpointDBE, + engine: TransactionsEngine = None, + ): + self.MCPEndpointDBE = MCPEndpointDBE + if engine is None: + engine = get_transactions_engine() + self.engine = engine + + @suppress_exceptions(exclude=[EntityCreationConflict]) + async def create_endpoint( + self, + *, + project_id: UUID, + user_id: UUID, + # + endpoint: MCPEndpointCreate, + ) -> Optional[MCPEndpoint]: + dbe = map_mcp_endpoint_create_to_dbe( + project_id=project_id, + user_id=user_id, + # + dto=endpoint, + ) + + try: + async with self.engine.session() as session: + session.add(dbe) + await session.commit() + await session.refresh(dbe) + + return map_mcp_endpoint_dbe_to_dto(dbe=dbe) + + except IntegrityError as e: + error_str = str(e.orig) if e.orig else str(e) + if "uq_mcps_endpoints_project_slug" in error_str: + raise EntityCreationConflict( + entity="MCPEndpoint", + message=f"MCP endpoint with slug '{endpoint.slug}' already exists.", + conflict={"slug": endpoint.slug}, + ) from e + raise + + @suppress_exceptions(default=None) + async def fetch_endpoint( + self, + *, + project_id: UUID, + # + endpoint_id: UUID, + ) -> Optional[MCPEndpoint]: + async with self.engine.session() as session: + stmt = ( + select(self.MCPEndpointDBE) + .filter(self.MCPEndpointDBE.project_id == project_id) + .filter(self.MCPEndpointDBE.id == endpoint_id) + .limit(1) + ) + + result = await session.execute(stmt) + dbe = result.scalars().first() + + if not dbe: + return None + + return map_mcp_endpoint_dbe_to_dto(dbe=dbe) + + @suppress_exceptions(default=None) + async def fetch_endpoint_by_slug( + self, + *, + project_id: UUID, + # + slug: str, + ) -> Optional[MCPEndpoint]: + async with self.engine.session() as session: + stmt = ( + select(self.MCPEndpointDBE) + .filter(self.MCPEndpointDBE.project_id == project_id) + .filter(self.MCPEndpointDBE.slug == slug) + .limit(1) + ) + + result = await session.execute(stmt) + dbe = result.scalars().first() + + if not dbe: + return None + + return map_mcp_endpoint_dbe_to_dto(dbe=dbe) + + @suppress_exceptions(default=None) + async def edit_endpoint( + self, + *, + project_id: UUID, + user_id: UUID, + # + endpoint: MCPEndpointEdit, + ) -> Optional[MCPEndpoint]: + async with self.engine.session() as session: + stmt = ( + select(self.MCPEndpointDBE) + .filter(self.MCPEndpointDBE.project_id == project_id) + .filter(self.MCPEndpointDBE.id == endpoint.id) + .limit(1) + ) + + result = await session.execute(stmt) + dbe = result.scalars().first() + + if not dbe: + return None + + dbe = map_mcp_endpoint_edit_to_dbe( + dbe=dbe, + user_id=user_id, + # + dto=endpoint, + ) + flag_modified(dbe, "data") + flag_modified(dbe, "flags") + + await session.commit() + await session.refresh(dbe) + + return map_mcp_endpoint_dbe_to_dto(dbe=dbe) + + @suppress_exceptions(default=False) + async def delete_endpoint( + self, + *, + project_id: UUID, + # + endpoint_id: UUID, + ) -> bool: + async with self.engine.session() as session: + stmt = ( + delete(self.MCPEndpointDBE) + .where(self.MCPEndpointDBE.project_id == project_id) + .where(self.MCPEndpointDBE.id == endpoint_id) + ) + + result = await session.execute(stmt) + await session.commit() + + return result.rowcount > 0 + + @suppress_exceptions(default=[]) + async def query_endpoints( + self, + *, + project_id: UUID, + # + endpoint: Optional[MCPEndpointQuery] = None, + # + windowing: Optional[Windowing] = None, + ) -> List[MCPEndpoint]: + async with self.engine.session() as session: + stmt = select(self.MCPEndpointDBE).filter( + self.MCPEndpointDBE.project_id == project_id, + ) + + if endpoint: + if endpoint.auth_mode is not None: + stmt = stmt.filter( + self.MCPEndpointDBE.auth_mode == endpoint.auth_mode + ) + + if endpoint.slug is not None: + stmt = stmt.filter(self.MCPEndpointDBE.slug == endpoint.slug) + + if windowing: + stmt = apply_windowing( + stmt=stmt, + DBE=self.MCPEndpointDBE, + attribute="id", + order="descending", + windowing=windowing, + ) + else: + stmt = stmt.order_by(self.MCPEndpointDBE.created_at.desc()) + + result = await session.execute(stmt) + dbes = result.scalars().all() + + return [map_mcp_endpoint_dbe_to_dto(dbe=dbe) for dbe in dbes] diff --git a/api/oss/src/dbs/postgres/gateways/mcps/dbas.py b/api/oss/src/dbs/postgres/gateways/mcps/dbas.py new file mode 100644 index 0000000000..718d9079aa --- /dev/null +++ b/api/oss/src/dbs/postgres/gateways/mcps/dbas.py @@ -0,0 +1,41 @@ +"""MCP plane DBA mixins (entities.md §2).""" + +from sqlalchemy import UUID, Column +from sqlalchemy import Enum as SQLEnum + +from oss.src.core.gateways.mcps.dtos import MCPAuthScheme +from oss.src.dbs.postgres.shared.dbas import ( + DataDBA, + FlagsDBA, + HeaderDBA, + IdentifierDBA, + LifecycleDBA, + MetaDBA, + ProjectScopeDBA, + SlugDBA, + StatusDBA, + TagsDBA, +) + + +class MCPEndpointDBA( + ProjectScopeDBA, + IdentifierDBA, + SlugDBA, + LifecycleDBA, + HeaderDBA, + DataDBA, + StatusDBA, + FlagsDBA, + TagsDBA, + MetaDBA, +): + """One custom MCP server: a registered upstream (entities.md §2).""" + + __abstract__ = True + + auth_mode = Column( + SQLEnum(MCPAuthScheme, name="gatewayauthscheme_enum"), nullable=False + ) + secret_id = Column(UUID(as_uuid=True), nullable=True) + # data: { route, tools, settings, oauth } — MCPEndpointData (entities.md §2.4) diff --git a/api/oss/src/dbs/postgres/gateways/mcps/dbes.py b/api/oss/src/dbs/postgres/gateways/mcps/dbes.py new file mode 100644 index 0000000000..34928b0059 --- /dev/null +++ b/api/oss/src/dbs/postgres/gateways/mcps/dbes.py @@ -0,0 +1,31 @@ +"""MCP plane DBEs (entities.md §3).""" + +from sqlalchemy import ( + ForeignKeyConstraint, + Index, + PrimaryKeyConstraint, + UniqueConstraint, +) + +from oss.src.dbs.postgres.gateways.mcps.dbas import MCPEndpointDBA +from oss.src.dbs.postgres.shared.base import Base + + +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", + ), + ) diff --git a/api/oss/src/dbs/postgres/gateways/mcps/mappings.py b/api/oss/src/dbs/postgres/gateways/mcps/mappings.py new file mode 100644 index 0000000000..0c41468563 --- /dev/null +++ b/api/oss/src/dbs/postgres/gateways/mcps/mappings.py @@ -0,0 +1,100 @@ +"""MCP plane DBE <-> DTO mappings (entities.md §2, §4.4).""" + +from datetime import datetime, timezone +from uuid import UUID + +from oss.src.core.gateways.dtos import GatewayEndpointNamespace +from oss.src.core.gateways.mcps.dtos import ( + MCPEndpoint, + MCPEndpointCreate, + MCPEndpointData, + MCPEndpointEdit, + MCPEndpointFlags, +) +from oss.src.core.shared.dtos import Status +from oss.src.dbs.postgres.gateways.mcps.dbes import MCPEndpointDBE + + +def map_mcp_endpoint_create_to_dbe( + *, + project_id: UUID, + user_id: UUID, + # + dto: MCPEndpointCreate, +) -> MCPEndpointDBE: + return MCPEndpointDBE( + project_id=project_id, + slug=dto.slug, + name=dto.name, + description=dto.description, + # + auth_mode=dto.auth_mode, + secret_id=dto.secret_id, + # + data=dto.data.model_dump(mode="json", exclude_none=True), + flags=dto.flags.model_dump(mode="json", exclude_none=True), + tags=dto.tags, + meta=dto.meta, + # + created_by_id=user_id, + ) + + +def map_mcp_endpoint_dbe_to_dto( + *, + dbe: MCPEndpointDBE, +) -> MCPEndpoint: + data = MCPEndpointData(**(dbe.data or {})) + flags = MCPEndpointFlags(**(dbe.flags or {})) + status = Status(**dbe.status) if dbe.status else None + + return MCPEndpoint( + id=dbe.id, + slug=dbe.slug, + name=dbe.name, + description=dbe.description, + # + auth_mode=dbe.auth_mode, + namespace=GatewayEndpointNamespace.CUSTOM, + secret_id=dbe.secret_id, + # BUILTIN-only fields (§2.3): never set on a row this package persists. + connection_id=None, + provider_key=None, + integration_key=None, + # + data=data, + flags=flags, + status=status, + tags=dbe.tags, + meta=dbe.meta, + # + created_at=dbe.created_at, + updated_at=dbe.updated_at, + deleted_at=dbe.deleted_at, + created_by_id=dbe.created_by_id, + updated_by_id=dbe.updated_by_id, + deleted_by_id=dbe.deleted_by_id, + ) + + +def map_mcp_endpoint_edit_to_dbe( + *, + dbe: MCPEndpointDBE, + user_id: UUID, + # + dto: MCPEndpointEdit, +) -> MCPEndpointDBE: + """Full PUT over the editable surface (§4.4): data, flags, header, secret_id, + and — unlike the LLM plane — auth_mode, which can move none -> oauth.""" + dbe.name = dto.name + dbe.description = dto.description + dbe.auth_mode = dto.auth_mode + dbe.secret_id = dto.secret_id + dbe.data = dto.data.model_dump(mode="json", exclude_none=True) + dbe.flags = dto.flags.model_dump(mode="json", exclude_none=True) + dbe.tags = dto.tags + dbe.meta = dto.meta + dbe.updated_at = datetime.now(timezone.utc) + dbe.updated_by_id = user_id + + return dbe diff --git a/api/oss/src/middlewares/auth.py b/api/oss/src/middlewares/auth.py index 591fd85435..a7cafb1444 100644 --- a/api/oss/src/middlewares/auth.py +++ b/api/oss/src/middlewares/auth.py @@ -1,4 +1,5 @@ from typing import Optional +from re import compile as re_compile from uuid import UUID from datetime import datetime, timezone, timedelta import asyncio @@ -49,6 +50,32 @@ _SECRET_TOKEN_PREFIX, ) +# The gateways' own credentials header (D31). It wins over `Authorization` everywhere, and +# on the gateway DATA PLANE it is the only header read at all — there `Authorization` +# belongs to the upstream, so a fallback would read the caller's vendor auth as ours. +_CREDENTIALS_HEADER = "X-AG-Credentials" + +# `/gateways/{plane}/{namespace}/...` is the data plane; `/gateways/{plane}/endpoints/...` +# is ordinary CRUD. No namespace can spell `endpoints`, which is what keeps the two apart +# under one mount prefix. +_GATEWAY_DATA_PLANE = re_compile( + r"^(?:/api)?/gateways/(?:llms|mcps)/(?:builtin|standard|custom)(?:/|$)" +) + + +def _credentials_header(request: Request) -> Optional[str]: + ours = request.headers.get(_CREDENTIALS_HEADER) or request.headers.get( + _CREDENTIALS_HEADER.lower() + ) + if ours or _GATEWAY_DATA_PLANE.match(request.url.path): + return ours + return ( + request.headers.get("Authorization") + or request.headers.get("authorization") + or None + ) + + _PUBLIC_ENDPOINTS = ( # AGENTA "/health", @@ -76,6 +103,10 @@ "/api/triggers/composio/events/", "/preview/triggers/composio/events/", "/api/preview/triggers/composio/events/", + # GATEWAYS — the MCP OAuth client identity document, fetched by an authorization + # server with no auth token (specs-wp20.md) + "/gateways/mcps/oauth/client-metadata.json", + "/api/gateways/mcps/oauth/client-metadata.json", ) _ADMIN_ENDPOINT_IDENTIFIER = "/admin/" @@ -263,11 +294,7 @@ async def _check_authentication_token(request: Request): return if _ADMIN_ENDPOINT_IDENTIFIER in request.url.path: - auth_header = ( - request.headers.get("Authorization") - or request.headers.get("authorization") - or None - ) + auth_header = _credentials_header(request) if not auth_header: raise UnauthorizedException() @@ -282,11 +309,7 @@ async def _check_authentication_token(request: Request): access_token=access_token, ) - auth_header = ( - request.headers.get("Authorization") - or request.headers.get("authorization") - or None - ) + auth_header = _credentials_header(request) supertokens_access_token = request.cookies.get("sAccessToken") query_project_id = request.query_params.get("project_id") diff --git a/api/oss/src/utils/env.py b/api/oss/src/utils/env.py index b20c6f45c3..c08bcb4304 100644 --- a/api/oss/src/utils/env.py +++ b/api/oss/src/utils/env.py @@ -721,6 +721,42 @@ def enabled(self) -> bool: model_config = ConfigDict(extra="ignore") +# --------------------------------------------------------------------------- +# gateways: mocks (WP5, D23) +# --------------------------------------------------------------------------- + + +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" + ) + + model_config = ConfigDict(extra="ignore") + + +# --------------------------------------------------------------------------- +# gateways: mcp adapter (WP8, D28) +# --------------------------------------------------------------------------- + + +class MCPGatewayConfig(BaseModel): + """`HttpMCPAdapter`'s outbound-guard escape hatch. Mirrors the runner's + `AGENTA_AGENT_MCPS_HOST_ALLOWLIST`: a `custom` MCP server whose host is + listed here skips the SSRF guard (`core/webhooks/utils.py`) entirely, so a + self-hoster can reach one known internal server without disabling the + guard globally via AGENTA_INSECURE_EGRESS_ALLOWED.""" + + host_allowlist: list[str] = _load_csv_env_list("AGENTA_MCP_GATEWAY_HOST_ALLOWLIST") + + model_config = ConfigDict(extra="ignore") + + # --------------------------------------------------------------------------- # crisp # --------------------------------------------------------------------------- @@ -1641,6 +1677,8 @@ class EnvironSettings(BaseModel): identity: IdentityConfig = IdentityConfig() llm: LLMConfig = LLMConfig() loops: LoopsConfig = LoopsConfig() + mcp_gateway: MCPGatewayConfig = MCPGatewayConfig() + mock_gateways: MockGatewaysConfig = MockGatewaysConfig() mounts: MountsConfig = MountsConfig() newrelic: NewRelicConfig = NewRelicConfig() postgres: PostgresConfig = PostgresConfig() diff --git a/api/oss/tests/pytest/acceptance/gateways/__init__.py b/api/oss/tests/pytest/acceptance/gateways/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/tests/pytest/acceptance/gateways/conftest.py b/api/oss/tests/pytest/acceptance/gateways/conftest.py new file mode 100644 index 0000000000..6d58d62202 --- /dev/null +++ b/api/oss/tests/pytest/acceptance/gateways/conftest.py @@ -0,0 +1,35 @@ +"""Fixtures for the gateway data plane. + +The data plane reads `X-AG-Credentials` and nothing else (D31), so its calls cannot use the +shared `authed_api`, which sets `Authorization`. That header is deliberately absent here: +on a data-plane request it belongs to the upstream, and sending one would exercise +pass-through rather than the path under test. +""" + +import pytest +import requests + +from oss.src.utils.env import env # noqa: F401 — keeps env import ordering with the suite + +BASE_TIMEOUT = 60 + + +@pytest.fixture +def gateway_api(cls_account): + """Authenticated data-plane requests: our credentials in our own header.""" + api_url = cls_account["api_url"] + credentials = cls_account["credentials"] + + def _request(method: str, endpoint: str, **kwargs): + headers = kwargs.pop("headers", {}) + headers.setdefault("X-AG-Credentials", credentials) + + return requests.request( + method=method, + url=f"{api_url}{endpoint}", + headers=headers, + timeout=BASE_TIMEOUT, + **kwargs, + ) + + return _request diff --git a/api/oss/tests/pytest/acceptance/gateways/test_llm_gateway_proxy_acceptance.py b/api/oss/tests/pytest/acceptance/gateways/test_llm_gateway_proxy_acceptance.py new file mode 100644 index 0000000000..11ce79adbf --- /dev/null +++ b/api/oss/tests/pytest/acceptance/gateways/test_llm_gateway_proxy_acceptance.py @@ -0,0 +1,278 @@ +"""Acceptance tests for the LLM data-plane proxy (workstreams/specs-wp6.md "Done test", +tasks-wp6.md Phase 6). + +WRITTEN, NOT RUN by this package: acceptance tests need a real deployment_kind (`api/AGENTS.md` +testing rules), and this worktree does not carry one. They also depend on work packages not +yet merged onto this branch — WP5 (`mock-llm-gateway`), WP7 (`LLMGatewayService`) and WP10 +(the LLM endpoints CRUD router this suite POSTs to, to seed the fixture endpoint) — collection +alone succeeds today (verified), but every test here fails until that M2 merge is deployed. +Run manually once that deployment_kind exists: + + load-env hosting/docker-compose/oss/.env.oss.dev + bash hosting/docker-compose/run.sh --oss --dev --build + cd api && py-run-tests # or: pytest oss/tests/pytest/acceptance/gateways -m acceptance + +Matches `plan.md` WP6's done condition verbatim: "a streamed response is relayed unmodified +and a hung upstream times out rather than hanging the gateway." +""" + +import time +from uuid import uuid4 + +import pytest + +# Compose service name and port WP5 owns (workstreams/specs-wp5.md); the trailing /v1 +# is the upstream's own path segment, appended to by RelayLLMAdapter's per-protocol +# routing strategy (specs-wp24.md, entities.md §9's base_url note). +_MOCK_BASE_URL = "http://mock-llm-gateway:9091/v1" + + +def _assert_ok(response): + assert response.status_code == 200, response.text + return response.json() + + +def _create_custom_endpoint(authed_api, *, models, timeout_seconds=None): + slug = f"wp6-acceptance-{uuid4().hex[:8]}" + body = _assert_ok( + authed_api( + "POST", + "/gateways/llms/endpoints/", + json={ + "endpoint": { + # deployment_kind "custom" (not "mock"): select_upstream sends + # LLMDeploymentKind.MOCK to the in-process MockLLMAdapter, which + # would never dial base_url. "custom" routes to RelayLLMAdapter, + # so these cross a real socket to the mock-llm-gateway container. + "slug": slug, + "provider_key": "openai", + "deployment_kind": "custom", + "secret_id": None, # GatewayAuthScheme.NONE — the mock needs no secret (D23) + "data": { + "route": {"base_url": _MOCK_BASE_URL}, + "models": {"allowlist": models}, + "settings": {"timeout_seconds": timeout_seconds}, + }, + } + }, + ) + ) + return body["endpoint"] + + +@pytest.fixture(scope="class") +def mock_llm_endpoint(authed_api): + """A custom endpoint pointed at WP5's mock upstream, allowlisting exactly the + model slugs this suite exercises against it (`mock/echo`).""" + return _create_custom_endpoint(authed_api, models=["mock/echo"]) + + +@pytest.mark.acceptance +class TestLLMGatewayProxyAcceptance: + def test_streaming_round_trips_sse_bytes_unmodified( + self, gateway_api, mock_llm_endpoint + ): + slug = mock_llm_endpoint["slug"] + + response = gateway_api( + "POST", + f"/gateways/llms/custom/{slug}/v1/chat/completions", + json={ + "model": "mock/echo", + "stream": True, + "messages": [{"role": "user", "content": "hi"}], + }, + ) + + assert response.status_code == 200 + # Byte comparison against the mock's own framing (specs-wp6.md: "byte + # comparison, not a re-decoded equivalence check") — every frame is + # `data: ...\n\n`, terminated by the literal `data: [DONE]\n\n` sentinel + # MockLLMAdapter emits (core/gateways/llms/providers/mock/adapter.py). + body = response.content + assert body.endswith(b"data: [DONE]\n\n") + assert body.count(b"data: ") >= 2 + + def test_non_streaming_call_returns_the_mocks_completion_body( + self, gateway_api, mock_llm_endpoint + ): + slug = mock_llm_endpoint["slug"] + + response = gateway_api( + "POST", + f"/gateways/llms/custom/{slug}/v1/chat/completions", + json={ + "model": "mock/echo", + "messages": [{"role": "user", "content": "hi"}], + }, + ) + + body = _assert_ok(response) + assert body["object"] == "chat.completion" + assert body["choices"][0]["message"]["role"] == "assistant" + + def test_slow_upstream_times_out_inside_the_configured_window_not_at_30s( + self, authed_api, gateway_api + ): + # A separate endpoint from `mock_llm_endpoint`: this one pins a short + # config.timeout_seconds so it is the GATEWAY, not curl and not the + # caller, that returns before mock/slow-30's 30-second sleep elapses. + endpoint = _create_custom_endpoint( + authed_api, + models=["mock/slow-30"], + timeout_seconds=3.0, + ) + slug = endpoint["slug"] + + started = time.monotonic() + response = gateway_api( + "POST", + f"/gateways/llms/custom/{slug}/v1/chat/completions", + json={ + "model": "mock/slow-30", + "messages": [{"role": "user", "content": "hi"}], + }, + ) + elapsed = time.monotonic() - started + + assert elapsed < 30, "the gateway's own request hung past the upstream's sleep" + assert response.status_code in (424, 502) + assert response.json()["error"]["code"] == "upstream_error" + + def test_unauthenticated_request_never_reaches_the_mock( + self, unauthed_api, mock_llm_endpoint + ): + slug = mock_llm_endpoint["slug"] + + # The auth middleware rejects before any router runs (D13) — this + # asserts the platform boundary; it has no direct handle on the mock's + # request log to prove non-delivery any more precisely than "no + # successful relay happened". + response = unauthed_api( + "POST", + f"/gateways/llms/custom/{slug}/v1/chat/completions", + json={ + "model": "mock/echo", + "messages": [{"role": "user", "content": "hi"}], + }, + ) + + assert response.status_code == 401 + + def test_model_outside_allowlist_is_refused_with_model_not_allowed( + self, gateway_api, mock_llm_endpoint + ): + slug = mock_llm_endpoint["slug"] + + response = gateway_api( + "POST", + f"/gateways/llms/custom/{slug}/v1/chat/completions", + json={ + "model": "mock/not-on-the-allowlist", + "messages": [{"role": "user", "content": "hi"}], + }, + ) + + assert response.status_code == 403 + assert response.json()["error"]["code"] == "model_not_allowed" + + def test_list_models_answers_the_endpoints_allowlist( + self, gateway_api, mock_llm_endpoint + ): + slug = mock_llm_endpoint["slug"] + + response = gateway_api("GET", f"/gateways/llms/custom/{slug}/v1/models") + + body = _assert_ok(response) + assert body["object"] == "list" + assert {m["id"] for m in body["data"]} == {"mock/echo"} + + +@pytest.mark.acceptance +class TestLLMGatewayResponsesAndMessagesDoorsAcceptance: + """D33/WP23/WP24: the same byte-for-byte relay, over the responses and messages doors. + + `RelayLLMAdapter`'s routing strategy is protocol-aware (specs-wp24.md): each door's + request lands on the mock's matching handler (`/v1/responses`, `/v1/messages`), not + always `/v1/chat/completions` — this proves both WP23's parse-only-the-policy-fields + property and WP24's per-protocol URL composition together. + """ + + def test_responses_door_relays_request_and_response_bytes( + self, gateway_api, mock_llm_endpoint + ): + slug = mock_llm_endpoint["slug"] + + response = gateway_api( + "POST", + f"/gateways/llms/custom/{slug}/v1/responses", + json={"model": "mock/echo", "input": [{"role": "user", "content": "hi"}]}, + ) + + assert response.status_code == 200 + + def test_messages_door_relays_request_and_response_bytes( + self, gateway_api, mock_llm_endpoint + ): + slug = mock_llm_endpoint["slug"] + + response = gateway_api( + "POST", + f"/gateways/llms/custom/{slug}/v1/messages", + json={ + "model": "mock/echo", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "hi"}], + }, + ) + + assert response.status_code == 200 + + def test_responses_door_streams_sse_bytes_unmodified( + self, gateway_api, mock_llm_endpoint + ): + slug = mock_llm_endpoint["slug"] + + response = gateway_api( + "POST", + f"/gateways/llms/custom/{slug}/v1/responses", + json={ + "model": "mock/echo", + "stream": True, + "input": [{"role": "user", "content": "hi"}], + }, + ) + + assert response.status_code == 200 + assert response.content.count(b"data: ") >= 1 + + def test_model_outside_allowlist_is_refused_on_the_messages_door_too( + self, gateway_api, mock_llm_endpoint + ): + slug = mock_llm_endpoint["slug"] + + response = gateway_api( + "POST", + f"/gateways/llms/custom/{slug}/v1/messages", + json={ + "model": "mock/not-on-the-allowlist", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "hi"}], + }, + ) + + assert response.status_code == 403 + assert response.json()["error"]["code"] == "model_not_allowed" + + +# D40/OD19 (specs-wp27.md): Vertex on the Messages door is the named byte-for-byte +# exemption above, and it composes the real rawPredict path (routing.py), which the mock +# upstream does not mount (`providers/mock/app.py` only serves +# `/v1/{chat/completions,responses,messages}`). Bedrock's Messages door now composes +# bedrock-mantle's own `/anthropic/v1/messages`, also unmounted by the mock. Proving each +# URL (+, for Vertex, body) pair therefore stays a unit test against RelayLLMAdapter +# directly +# (test_gateways_llm_relay_adapter.py::test_{bedrock_messages_request_composes_mantle_url_ +# and_leaves_body_untouched,vertex_messages_request_moves_model_from_body_to_url}) rather +# than an acceptance test here — there is no upstream in this suite's reach that speaks +# either real wire. diff --git a/api/oss/tests/pytest/acceptance/gateways/test_mcp_gateway_proxy_acceptance.py b/api/oss/tests/pytest/acceptance/gateways/test_mcp_gateway_proxy_acceptance.py new file mode 100644 index 0000000000..d6637db209 --- /dev/null +++ b/api/oss/tests/pytest/acceptance/gateways/test_mcp_gateway_proxy_acceptance.py @@ -0,0 +1,164 @@ +"""Acceptance tests for the MCP data-plane proxy (specs-wp8.md "Done test"). + +specs-wp8.md rests WP8's done claim on two checks running against a deployment_kind: a +byte-for-byte relay end to end, and the refusal of a tool outside the endpoint's policy. +specs-wp9.md cross-references the same pair. Mirrors the LLM module next door. + +Needs a real deployment_kind (api/AGENTS.md's test-layer rule). 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 api && pytest oss/tests/pytest/acceptance/gateways -m acceptance +""" + +import json +from uuid import uuid4 + +import pytest + +# Compose service name and port WP5 owns; the mock speaks Streamable HTTP in JSON mode +# at the root path, so no trailing segment. +_MOCK_BASE_URL = "http://mock-mcp-gateway:9092/" + + +def _assert_ok(response): + assert response.status_code == 200, response.text + return response.json() + + +def _create_custom_endpoint(authed_api, *, tools=None): + slug = f"wp8-acceptance-{uuid4().hex[:8]}" + data = {"route": {"base_url": _MOCK_BASE_URL}} + if tools is not None: + data["tools"] = tools + + body = _assert_ok( + authed_api( + "POST", + "/gateways/mcps/endpoints/", + json={ + "endpoint": { + "slug": slug, + "auth_mode": "none", # the mock needs no secret (D23) + "secret_id": None, + "data": data, + } + }, + ) + ) + return body["endpoint"] + + +def _call(gateway_api, slug, payload): + """Route by header, the way a client does: the gateway never parses the body.""" + headers = {"MCP-Method": payload["method"]} + target = (payload.get("params") or {}).get("name") + if target: + headers["MCP-Name"] = target + + return gateway_api( + "POST", f"/gateways/mcps/custom/{slug}", json=payload, headers=headers + ) + + +@pytest.fixture(scope="class") +def mock_mcp_endpoint(authed_api): + """An endpoint with the default ALL tool policy, so nothing is filtered.""" + return _create_custom_endpoint(authed_api) + + +@pytest.mark.acceptance +class TestMCPGatewayProxyAcceptance: + def test_tools_list_relays_the_upstream_answer( + self, gateway_api, mock_mcp_endpoint + ): + response = _call( + gateway_api, + mock_mcp_endpoint["slug"], + {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + ) + + body = _assert_ok(response) + assert {tool["name"] for tool in body["result"]["tools"]} == { + "echo", + "fail", + "slow", + } + + def test_tools_call_round_trips_the_upstream_result_unmodified( + self, gateway_api, mock_mcp_endpoint + ): + payload = { + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": {"name": "echo", "arguments": {"text": "hello"}}, + } + + response = _call(gateway_api, mock_mcp_endpoint["slug"], payload) + + body = _assert_ok(response) + assert body["id"] == 7 + assert "hello" in json.dumps(body["result"]) + + def test_a_failing_tool_relays_as_a_result_not_a_transport_error( + self, gateway_api, mock_mcp_endpoint + ): + # D16: the server's own failure reason is what lets a model correct itself, so + # it travels as the response body, never as a gateway error. + response = _call( + gateway_api, + mock_mcp_endpoint["slug"], + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "fail"}, + }, + ) + + body = _assert_ok(response) + assert body["result"]["isError"] is True + + def test_tool_outside_the_policy_is_refused_before_the_upstream( + self, authed_api, gateway_api + ): + endpoint = _create_custom_endpoint(authed_api, tools={"allowlist": ["echo"]}) + + response = _call( + gateway_api, + endpoint["slug"], + { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "fail"}, + }, + ) + + assert response.status_code == 403, response.text + assert response.json()["error"]["data"]["cause"] == "tool_not_allowed" + + def test_an_include_policy_filters_the_listing(self, authed_api, gateway_api): + endpoint = _create_custom_endpoint(authed_api, tools={"allowlist": ["echo"]}) + + response = _call( + gateway_api, + endpoint["slug"], + {"jsonrpc": "2.0", "id": 4, "method": "tools/list"}, + ) + + body = _assert_ok(response) + # The body is rewritten here, so a relayed content-length would be stale. + assert {tool["name"] for tool in body["result"]["tools"]} == {"echo"} + + def test_unauthenticated_request_never_reaches_the_upstream( + self, unauthed_api, mock_mcp_endpoint + ): + response = unauthed_api( + "POST", + f"/gateways/mcps/custom/{mock_mcp_endpoint['slug']}", + json={"jsonrpc": "2.0", "id": 5, "method": "tools/list"}, + ) + + assert response.status_code in (401, 403), response.text diff --git a/api/oss/tests/pytest/integration/gateways/__init__.py b/api/oss/tests/pytest/integration/gateways/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/tests/pytest/integration/gateways/conftest.py b/api/oss/tests/pytest/integration/gateways/conftest.py new file mode 100644 index 0000000000..be6988d8dc --- /dev/null +++ b/api/oss/tests/pytest/integration/gateways/conftest.py @@ -0,0 +1,124 @@ +import uuid + +import pytest +from sqlalchemy import text + +import oss.src.dbs.postgres.secrets.dbes # noqa: F401 — registers `secrets` +import oss.src.dbs.postgres.shared.engine as engine_module +import oss.src.models.db_models # noqa: F401 — registers `projects`; both FK targets +from oss.src.dbs.postgres.shared.engine import get_transactions_engine +from oss.tests.pytest.utils.postgres import use_reachable_core_uri + + +@pytest.fixture(autouse=True) +def _skip_when_postgres_unreachable(request): + # Keyed on the fixture, not the directory: the mock-upstream module lives here too + # and needs the two mock services, never Postgres. + if "seeded_project" not in request.fixturenames: + return + if use_reachable_core_uri() is None: + pytest.skip("Postgres not reachable — skipping gateways DAO integration tests") + + +@pytest.fixture(autouse=True) +async def _fresh_engine_per_test(): + # asyncpg binds its connections to the loop that opened them, and each test gets a + # new loop — a cached engine from an earlier test fails with "attached to a + # different loop" (same fixture as integration/sessions). + engine_module._transactions_engine = None + yield + if engine_module._transactions_engine is not None: + await engine_module._transactions_engine.close() + engine_module._transactions_engine = None + + +@pytest.fixture +async def seeded_project(): + """Provision the FK chain (org -> workspace -> project) and one bare + `secrets` row, so llms_endpoints.secret_id / mcps_endpoints.secret_id + have a real target to reference. The row's `data` is intentionally NULL — these + tests never decrypt it, they only exercise the FK's ondelete behaviour.""" + engine = get_transactions_engine() + user_id = uuid.uuid4() + organization_id = uuid.uuid4() + workspace_id = uuid.uuid4() + project_id = uuid.uuid4() + secret_id = uuid.uuid4() + + async with engine.session() as session: + await session.execute( + text( + "INSERT INTO users (id, uid, username, email) " + "VALUES (:id, :uid, :username, :email)" + ), + { + "id": user_id, + "uid": str(user_id), + "username": "gateways-dao-test", + "email": f"gateways-dao-{user_id.hex[:8]}@example.com", + }, + ) + await session.execute( + text( + "INSERT INTO organizations (id, name, owner_id) " + "VALUES (:id, :name, :owner_id)" + ), + {"id": organization_id, "name": "gw-org", "owner_id": user_id}, + ) + await session.execute( + text( + "INSERT INTO workspaces (id, name, organization_id) " + "VALUES (:id, :name, :organization_id)" + ), + {"id": workspace_id, "name": "gw-ws", "organization_id": organization_id}, + ) + await session.execute( + text( + "INSERT INTO projects " + "(id, project_name, workspace_id, organization_id) " + "VALUES (:id, :name, :workspace_id, :organization_id)" + ), + { + "id": project_id, + "name": "gw-project", + "workspace_id": workspace_id, + "organization_id": organization_id, + }, + ) + await session.execute( + text("INSERT INTO secrets (id, project_id) VALUES (:id, :project_id)"), + {"id": secret_id, "project_id": project_id}, + ) + await session.commit() + + yield { + "project_id": project_id, + "user_id": user_id, + "secret_id": secret_id, + } + + async with engine.session() as session: + await session.execute( + text("DELETE FROM llms_endpoints WHERE project_id = :project_id"), + {"project_id": project_id}, + ) + await session.execute( + text("DELETE FROM mcps_endpoints WHERE project_id = :project_id"), + {"project_id": project_id}, + ) + await session.execute( + text("DELETE FROM secrets WHERE project_id = :project_id"), + {"project_id": project_id}, + ) + await session.execute( + text("DELETE FROM projects WHERE id = :id"), {"id": project_id} + ) + await session.execute( + text("DELETE FROM workspaces WHERE id = :id"), {"id": workspace_id} + ) + await session.execute( + text("DELETE FROM organizations WHERE id = :id"), + {"id": organization_id}, + ) + await session.execute(text("DELETE FROM users WHERE id = :id"), {"id": user_id}) + await session.commit() diff --git a/api/oss/tests/pytest/integration/gateways/test_gateways_llm_endpoints_dao.py b/api/oss/tests/pytest/integration/gateways/test_gateways_llm_endpoints_dao.py new file mode 100644 index 0000000000..0a779b3250 --- /dev/null +++ b/api/oss/tests/pytest/integration/gateways/test_gateways_llm_endpoints_dao.py @@ -0,0 +1,256 @@ +"""LLMEndpointsDAO against real Postgres (entities.md §7, WP1 exit condition). + +Needs a live deployment_kind — write, do not run without one (`api/AGENTS.md`'s +test-layer rule). +""" + +import pytest +from sqlalchemy import text + +from oss.src.core.gateways.dtos import GatewayEndpointNamespace +from oss.src.core.gateways.llms.dtos import ( + LLMDeploymentKind, + LLMEndpointCreate, + LLMEndpointData, + LLMEndpointEdit, + LLMEndpointQuery, + LLMEndpointRoute, + LLMEndpointSettings, + LLMModelFilter, +) +from oss.src.core.shared.exceptions import EntityCreationConflict +from oss.src.dbs.postgres.gateways.llms.dao import LLMEndpointsDAO +from oss.src.dbs.postgres.shared.engine import get_transactions_engine + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +def _create_dto(*, slug: str) -> LLMEndpointCreate: + return LLMEndpointCreate( + slug=slug, + name="Acme Azure", + provider_key="azure", + deployment_kind=LLMDeploymentKind.AZURE, + data=LLMEndpointData( + route=LLMEndpointRoute(base_url="http://mock-llm-gateway:9091/azure"), + models=LLMModelFilter(allowlist=["gpt-4o"]), + settings=LLMEndpointSettings(max_output_tokens=4096), + ), + ) + + +async def test_create_then_fetch_round_trips_field_for_field(seeded_project): + dao = LLMEndpointsDAO(engine=get_transactions_engine()) + project_id = seeded_project["project_id"] + user_id = seeded_project["user_id"] + secret_id = seeded_project["secret_id"] + + create = _create_dto(slug="acme-azure") + create.secret_id = secret_id + + created = await dao.create_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=create, + ) + assert created is not None + assert created.namespace == GatewayEndpointNamespace.CUSTOM + + fetched = await dao.fetch_endpoint( + project_id=project_id, + # + endpoint_id=created.id, + ) + assert fetched is not None + assert fetched.slug == create.slug + assert fetched.provider_key == create.provider_key + assert fetched.deployment_kind == create.deployment_kind + assert fetched.secret_id == secret_id + assert fetched.data.route.base_url == create.data.route.base_url + assert fetched.data.models.allowlist == create.data.models.allowlist + + by_slug = await dao.fetch_endpoint_by_slug( + project_id=project_id, + # + slug=create.slug, + ) + assert by_slug is not None + assert by_slug.id == created.id + + +async def test_duplicate_slug_raises_entity_creation_conflict(seeded_project): + dao = LLMEndpointsDAO(engine=get_transactions_engine()) + project_id = seeded_project["project_id"] + user_id = seeded_project["user_id"] + + await dao.create_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=_create_dto(slug="acme-dup"), + ) + + with pytest.raises(EntityCreationConflict) as excinfo: + await dao.create_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=_create_dto(slug="acme-dup"), + ) + assert excinfo.value.conflict == {"slug": "acme-dup"} + + +async def test_edit_endpoint_replaces_data_and_flags_wholesale(seeded_project): + dao = LLMEndpointsDAO(engine=get_transactions_engine()) + project_id = seeded_project["project_id"] + user_id = seeded_project["user_id"] + + created = await dao.create_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=_create_dto(slug="acme-edit"), + ) + assert created.data.route.extras is None + assert created.data.settings.max_output_tokens == 4096 + + edit = LLMEndpointEdit( + id=created.id, + data=LLMEndpointData( + route=LLMEndpointRoute(base_url="http://mock-llm-gateway:9092/azure"), + models=LLMModelFilter(allowlist=["gpt-4o-mini"]), + # the original's `models` allowlist is gone unless + # repeated here — this is a PUT, not a PATCH. + ), + ) + + edited = await dao.edit_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=edit, + ) + assert edited is not None + assert edited.data.route.base_url == "http://mock-llm-gateway:9092/azure" + assert edited.data.models.allowlist == ["gpt-4o-mini"] + assert edited.data.settings.max_output_tokens is None + assert edited.updated_by_id == user_id + assert edited.updated_at is not None + + refetched = await dao.fetch_endpoint( + project_id=project_id, + # + endpoint_id=created.id, + ) + assert refetched.data.models.allowlist == ["gpt-4o-mini"] + + +async def test_delete_endpoint_is_idempotent(seeded_project): + dao = LLMEndpointsDAO(engine=get_transactions_engine()) + project_id = seeded_project["project_id"] + user_id = seeded_project["user_id"] + + created = await dao.create_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=_create_dto(slug="acme-delete"), + ) + + first = await dao.delete_endpoint( + project_id=project_id, + # + endpoint_id=created.id, + ) + assert first is True + + second = await dao.delete_endpoint( + project_id=project_id, + # + endpoint_id=created.id, + ) + assert second is False + + assert ( + await dao.fetch_endpoint(project_id=project_id, endpoint_id=created.id) is None + ) + + +async def test_query_endpoints_filters_by_provider_and_deployment(seeded_project): + dao = LLMEndpointsDAO(engine=get_transactions_engine()) + project_id = seeded_project["project_id"] + user_id = seeded_project["user_id"] + + azure = await dao.create_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=_create_dto(slug="acme-query-azure"), + ) + direct = LLMEndpointCreate( + slug="acme-query-direct", + provider_key="openai", + deployment_kind=LLMDeploymentKind.DIRECT, + data=LLMEndpointData(models=LLMModelFilter(allowlist=["gpt-4o"])), + ) + openai = await dao.create_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=direct, + ) + + by_provider = await dao.query_endpoints( + project_id=project_id, + # + endpoint=LLMEndpointQuery(provider_key="azure"), + ) + assert {e.id for e in by_provider} == {azure.id} + + by_deployment = await dao.query_endpoints( + project_id=project_id, + # + endpoint=LLMEndpointQuery(deployment_kind=LLMDeploymentKind.DIRECT), + ) + assert {e.id for e in by_deployment} == {openai.id} + + by_slug = await dao.query_endpoints( + project_id=project_id, + # + endpoint=LLMEndpointQuery(slug="acme-query-azure"), + ) + assert {e.id for e in by_slug} == {azure.id} + + +async def test_deleting_secret_sets_endpoint_secret_id_null(seeded_project): + """D18/§2.1: a dead secret must not silently delete configuration.""" + dao = LLMEndpointsDAO(engine=get_transactions_engine()) + project_id = seeded_project["project_id"] + user_id = seeded_project["user_id"] + secret_id = seeded_project["secret_id"] + + create = _create_dto(slug="acme-fk-set-null") + create.secret_id = secret_id + created = await dao.create_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=create, + ) + assert created.secret_id == secret_id + + engine = get_transactions_engine() + async with engine.session() as session: + await session.execute( + text("DELETE FROM secrets WHERE id = :id"), {"id": secret_id} + ) + await session.commit() + + survivor = await dao.fetch_endpoint( + project_id=project_id, + # + endpoint_id=created.id, + ) + assert survivor is not None + assert survivor.secret_id is None diff --git a/api/oss/tests/pytest/integration/gateways/test_gateways_mcp_endpoints_dao.py b/api/oss/tests/pytest/integration/gateways/test_gateways_mcp_endpoints_dao.py new file mode 100644 index 0000000000..877cb12e4d --- /dev/null +++ b/api/oss/tests/pytest/integration/gateways/test_gateways_mcp_endpoints_dao.py @@ -0,0 +1,246 @@ +"""MCPEndpointsDAO against real Postgres (entities.md §7, WP1 exit condition). + +Needs a live deployment_kind — write, do not run without one. +""" + +import pytest +from sqlalchemy import text + +from oss.src.core.gateways.mcps.dtos import MCPAuthScheme, GatewayEndpointNamespace +from oss.src.core.gateways.mcps.dtos import ( + MCPEndpointSettings, + MCPEndpointCreate, + MCPEndpointData, + MCPEndpointEdit, + MCPEndpointQuery, + MCPEndpointRoute, + MCPToolFilter, +) +from oss.src.core.shared.exceptions import EntityCreationConflict +from oss.src.dbs.postgres.gateways.mcps.dao import MCPEndpointsDAO +from oss.src.dbs.postgres.shared.engine import get_transactions_engine + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +def _create_dto(*, slug: str) -> MCPEndpointCreate: + return MCPEndpointCreate( + slug=slug, + name="Acme Notion", + auth_mode=MCPAuthScheme.OAUTH, + data=MCPEndpointData( + route=MCPEndpointRoute(base_url="https://mcp.acme.com"), + tools=MCPToolFilter(allowlist=["search"]), + settings=MCPEndpointSettings(timeout_seconds=10.0), + ), + ) + + +async def test_create_then_fetch_round_trips_field_for_field(seeded_project): + dao = MCPEndpointsDAO(engine=get_transactions_engine()) + project_id = seeded_project["project_id"] + user_id = seeded_project["user_id"] + secret_id = seeded_project["secret_id"] + + create = _create_dto(slug="acme-notion") + create.secret_id = secret_id + + created = await dao.create_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=create, + ) + assert created is not None + assert created.namespace == GatewayEndpointNamespace.CUSTOM + assert created.connection_id is None + assert created.provider_key is None + assert created.integration_key is None + + fetched = await dao.fetch_endpoint( + project_id=project_id, + # + endpoint_id=created.id, + ) + assert fetched is not None + assert fetched.slug == create.slug + assert fetched.auth_mode == MCPAuthScheme.OAUTH + assert fetched.secret_id == secret_id + assert fetched.data.route.base_url == create.data.route.base_url + assert fetched.data.tools.allowlist == ["search"] + + by_slug = await dao.fetch_endpoint_by_slug( + project_id=project_id, + # + slug=create.slug, + ) + assert by_slug is not None + assert by_slug.id == created.id + + +async def test_duplicate_slug_raises_entity_creation_conflict(seeded_project): + dao = MCPEndpointsDAO(engine=get_transactions_engine()) + project_id = seeded_project["project_id"] + user_id = seeded_project["user_id"] + + await dao.create_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=_create_dto(slug="acme-notion-dup"), + ) + + with pytest.raises(EntityCreationConflict) as excinfo: + await dao.create_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=_create_dto(slug="acme-notion-dup"), + ) + assert excinfo.value.conflict == {"slug": "acme-notion-dup"} + + +async def test_edit_endpoint_replaces_data_and_flags_wholesale(seeded_project): + dao = MCPEndpointsDAO(engine=get_transactions_engine()) + project_id = seeded_project["project_id"] + user_id = seeded_project["user_id"] + + created = await dao.create_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=_create_dto(slug="acme-notion-edit"), + ) + assert created.data.route.headers is None + + edit = MCPEndpointEdit( + id=created.id, + auth_mode=MCPAuthScheme.NONE, + data=MCPEndpointData( + route=MCPEndpointRoute(base_url="https://mcp2.acme.com"), + # tools omitted from the new document -> reverts to unconstrained, not + # preserved from the original INCLUDE — this is a PUT, not a PATCH. + ), + ) + + edited = await dao.edit_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=edit, + ) + assert edited is not None + assert edited.auth_mode == MCPAuthScheme.NONE + assert edited.data.route.base_url == "https://mcp2.acme.com" + assert edited.data.tools.allowlist is None + assert edited.updated_by_id == user_id + + refetched = await dao.fetch_endpoint( + project_id=project_id, + # + endpoint_id=created.id, + ) + assert refetched.data.tools.allowlist is None + + +async def test_delete_endpoint_is_idempotent(seeded_project): + dao = MCPEndpointsDAO(engine=get_transactions_engine()) + project_id = seeded_project["project_id"] + user_id = seeded_project["user_id"] + + created = await dao.create_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=_create_dto(slug="acme-notion-delete"), + ) + + first = await dao.delete_endpoint( + project_id=project_id, + # + endpoint_id=created.id, + ) + assert first is True + + second = await dao.delete_endpoint( + project_id=project_id, + # + endpoint_id=created.id, + ) + assert second is False + + assert ( + await dao.fetch_endpoint(project_id=project_id, endpoint_id=created.id) is None + ) + + +async def test_query_endpoints_filters_by_auth_mode_and_slug(seeded_project): + dao = MCPEndpointsDAO(engine=get_transactions_engine()) + project_id = seeded_project["project_id"] + user_id = seeded_project["user_id"] + + oauth_endpoint = await dao.create_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=_create_dto(slug="acme-notion-query-oauth"), + ) + none_endpoint = await dao.create_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=MCPEndpointCreate( + slug="acme-notion-query-none", + auth_mode=MCPAuthScheme.NONE, + data=MCPEndpointData( + route=MCPEndpointRoute(base_url="https://open.acme.com") + ), + ), + ) + + by_auth_mode = await dao.query_endpoints( + project_id=project_id, + # + endpoint=MCPEndpointQuery(auth_mode=MCPAuthScheme.NONE), + ) + assert {e.id for e in by_auth_mode} == {none_endpoint.id} + + by_slug = await dao.query_endpoints( + project_id=project_id, + # + endpoint=MCPEndpointQuery(slug="acme-notion-query-oauth"), + ) + assert {e.id for e in by_slug} == {oauth_endpoint.id} + + +async def test_deleting_secret_sets_endpoint_secret_id_null(seeded_project): + """D18/§2.1: a dead secret must not silently delete configuration.""" + dao = MCPEndpointsDAO(engine=get_transactions_engine()) + project_id = seeded_project["project_id"] + user_id = seeded_project["user_id"] + secret_id = seeded_project["secret_id"] + + create = _create_dto(slug="acme-notion-fk-set-null") + create.secret_id = secret_id + created = await dao.create_endpoint( + project_id=project_id, + user_id=user_id, + # + endpoint=create, + ) + assert created.secret_id == secret_id + + engine = get_transactions_engine() + async with engine.session() as session: + await session.execute( + text("DELETE FROM secrets WHERE id = :id"), {"id": secret_id} + ) + await session.commit() + + survivor = await dao.fetch_endpoint( + project_id=project_id, + # + endpoint_id=created.id, + ) + assert survivor is not None + assert survivor.secret_id is None diff --git a/api/oss/tests/pytest/integration/gateways/test_mock_upstreams.py b/api/oss/tests/pytest/integration/gateways/test_mock_upstreams.py new file mode 100644 index 0000000000..cb7ff2b8b9 --- /dev/null +++ b/api/oss/tests/pytest/integration/gateways/test_mock_upstreams.py @@ -0,0 +1,139 @@ +"""The mock upstreams answer, and can still be driven to fail and to hang. + +WP5's own contract (workstreams/specs-wp5.md): the mocks must run as compose services and +be drivable to fail/hang on demand from a real HTTP client, not a mocked transport. Every +suite that points an endpoint at them inherits that assumption, so it is checked directly +here rather than left implicit. + +Needs the compose stack up; skips with a reason when it is not. +""" + +import socket +from functools import lru_cache +from typing import Optional +from urllib.parse import urlparse + +import httpx +import pytest + +from oss.src.utils.env import env + + +def _reachable(host: str, port: int) -> bool: + try: + with socket.create_connection((host, port), timeout=0.5): + return True + except OSError: + return False + + +@lru_cache(maxsize=2) +def _resolve(url: str, default_port: int) -> Optional[str]: + """The compose service name in-network, else the published loopback port. + + Both dev compose files publish these on 127.0.0.1, so a host-side run reaches the + same containers without the env var having to lie to the API container, which needs + the service name. + """ + parsed = urlparse(url) + port = parsed.port or default_port + if parsed.hostname and _reachable(parsed.hostname, port): + return url + if _reachable("127.0.0.1", port): + return f"http://127.0.0.1:{port}" + return None + + +_LLM_URL = _resolve(env.mock_gateways.llm_url, 9091) +_MCP_URL = _resolve(env.mock_gateways.mcp_url, 9092) + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + _LLM_URL is None or _MCP_URL is None, + reason="mock gateway services not reachable — deploy the compose stack", + ), +] + + +class TestMockUpstreams: + async def test_both_healthchecks_answer(self): + async with httpx.AsyncClient() as client: + llm = await client.get(f"{_LLM_URL}/health") + mcp = await client.get(f"{_MCP_URL}/health") + + assert llm.status_code == 200, llm.text + assert mcp.status_code == 200, mcp.text + + async def test_error_model_returns_500(self): + async with httpx.AsyncClient(base_url=_LLM_URL) as client: + response = await client.post( + "/v1/chat/completions", json={"model": "mock/error", "messages": []} + ) + + assert response.status_code == 500, response.text + + async def test_echo_model_streams_sse_frames_ending_done(self): + async with httpx.AsyncClient(base_url=_LLM_URL) as client: + async with client.stream( + "POST", + "/v1/chat/completions", + json={ + "model": "mock/echo", + "stream": True, + "messages": [{"role": "user", "content": "hi"}], + }, + ) as response: + content_type = response.headers["content-type"] + frames = [ + line + async for line in response.aiter_lines() + if line.startswith("data:") + ] + + assert content_type.startswith("text/event-stream"), content_type + assert len(frames) > 1, frames + assert frames[-1] == "data: [DONE]", frames[-1] + + async def test_slow_model_hangs_past_a_short_client_timeout(self): + # A real socket left open, not a mocked await: without this the gateway's own + # timeout handling has nothing to time out against. + async with httpx.AsyncClient(base_url=_LLM_URL, timeout=2.0) as client: + with pytest.raises(httpx.TimeoutException): + await client.post( + "/v1/chat/completions", + json={"model": "mock/slow-30", "messages": []}, + ) + + async def test_tools_list_returns_three_tools_and_get_delete_are_405(self): + async with httpx.AsyncClient(base_url=_MCP_URL) as client: + listed = await client.post( + "/", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"} + ) + got = await client.get("/") + deleted = await client.delete("/") + + assert listed.status_code == 200, listed.text + assert {tool["name"] for tool in listed.json()["result"]["tools"]} == { + "echo", + "fail", + "slow", + } + assert got.status_code == 405 + assert deleted.status_code == 405 + + async def test_failing_tool_returns_is_error_at_http_200(self): + async with httpx.AsyncClient(base_url=_MCP_URL) as client: + response = await client.post( + "/", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "fail"}, + }, + ) + + # A tool that fails is a protocol-level result, never a transport error (D16). + assert response.status_code == 200, response.text + assert response.json()["result"]["isError"] is True diff --git a/api/oss/tests/pytest/integration/sessions/conftest.py b/api/oss/tests/pytest/integration/sessions/conftest.py index 6f4a48387e..9f14611c84 100644 --- a/api/oss/tests/pytest/integration/sessions/conftest.py +++ b/api/oss/tests/pytest/integration/sessions/conftest.py @@ -1,32 +1,12 @@ -import socket -from functools import lru_cache -from urllib.parse import urlparse - import pytest -from oss.src.utils.env import env - - -@lru_cache(maxsize=1) -def _postgres_reachable() -> bool: - """TCP-probe the configured core Postgres once per session. - - The integration DAO tests here need a real Postgres. The default URI points - at the Docker-network host `postgres:5432`, which resolves in-compose/CI but - not on a bare host (`load-env` leaves it commented). Probe rather than error - so a native `py-run-tests --api` skips these instead of failing setup. - """ - parsed = urlparse(env.postgres.uri_core) - host = parsed.hostname or "postgres" - port = parsed.port or 5432 - try: - with socket.create_connection((host, port), timeout=0.5): - return True - except OSError: - return False +from oss.tests.pytest.utils.postgres import use_reachable_core_uri @pytest.fixture(autouse=True) def _skip_when_postgres_unreachable(request): - if request.node.get_closest_marker("integration") and not _postgres_reachable(): + if ( + request.node.get_closest_marker("integration") + and use_reachable_core_uri() is None + ): pytest.skip("Postgres not reachable — skipping session DAO integration tests") diff --git a/api/oss/tests/pytest/unit/gateways/__init__.py b/api/oss/tests/pytest/unit/gateways/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_http_mcp_adapter.py b/api/oss/tests/pytest/unit/gateways/test_gateways_http_mcp_adapter.py new file mode 100644 index 0000000000..7671ab2eda --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_http_mcp_adapter.py @@ -0,0 +1,511 @@ +"""Unit tests for HttpMCPAdapter (entities.md §7.1, workstreams/specs-wp8.md). + +Nothing running: httpx.MockTransport stands in for the upstream — no real network, no +real MCP server. SSRF-guard tests monkeypatch +`oss.src.core.webhooks.utils._WEBHOOK_ALLOW_INSECURE` directly rather than the env var +(the same technique `unit/webhooks/test_webhooks_utils.py` uses, since the flag is read +once at import time into that module-level constant) — this is what "set +AGENTA_INSECURE_EGRESS_ALLOWED=false explicitly" means operationally: the guard must be +live, not defaulted off, for every one of these cases. +""" + +from types import SimpleNamespace + +import httpx +import pytest + +from oss.src.core.gateways.mcps.dtos import ( + MCPBrokeredAuth, + MCPCallContext, + MCPDirectAuth, + MCPEndpointSettings, + MCPResolvedRoute, +) +from oss.src.core.gateways.mcps.interfaces import MCPRelayResult +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.types import MCPUpstreamError + +_PUBLIC_IP = ( + "93.184.216.34" # example.com — routable, non-private (webhooks test precedent) +) + + +def _context() -> MCPCallContext: + return MCPCallContext(method="tools/list") + + +def _auth(*, secret=None) -> MCPDirectAuth: + return MCPDirectAuth(secret=secret) + + +def _json_response(status_code: int = 200, **body) -> httpx.Response: + return httpx.Response( + status_code, json=body or {"jsonrpc": "2.0", "id": 1, "result": {}} + ) + + +@pytest.fixture(autouse=True) +def _secure_egress(monkeypatch): + """Every test in this module runs with the guard live unless a test overrides it — + AGENTA_INSECURE_EGRESS_ALLOWED=false, set explicitly rather than relied on as a + default.""" + monkeypatch.setattr("oss.src.core.webhooks.utils._WEBHOOK_ALLOW_INSECURE", False) + + +@pytest.fixture(autouse=True) +def _empty_host_allowlist(monkeypatch): + monkeypatch.setattr( + "oss.src.core.gateways.mcps.providers.http.adapter.env.mcp_gateway.host_allowlist", + [], + ) + + +# --------------------------------------------------------------------------- +# Transparent relay +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_body_passed_through_byte_for_byte(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["content"] = request.content + return _json_response() + + adapter = HttpMCPAdapter(transport=httpx.MockTransport(handler)) + sent_body = b'{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' + + await adapter.relay( + route=MCPResolvedRoute(url=f"https://{_PUBLIC_IP}/mcp"), + auth=_auth(), + context=_context(), + body=sent_body, + headers={}, + ) + + assert captured["content"] == sent_body + + +@pytest.mark.asyncio +async def test_route_and_caller_headers_both_present(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["headers"] = request.headers + return _json_response() + + adapter = HttpMCPAdapter(transport=httpx.MockTransport(handler)) + + await adapter.relay( + route=MCPResolvedRoute( + url=f"https://{_PUBLIC_IP}/mcp", headers={"X-Route-Header": "route"} + ), + auth=_auth(), + context=_context(), + body=b"{}", + headers={"X-Caller-Header": "caller"}, + ) + + assert captured["headers"]["X-Route-Header"] == "route" + assert captured["headers"]["X-Caller-Header"] == "caller" + + +@pytest.mark.asyncio +async def test_caller_header_wins_on_collision_with_route_header(): + """route.headers is merged UNDER the caller's forwarded headers (specs-wp8.md §7.1), + so on a name collision the caller's value is what reaches the upstream. entities.md + does not mandate this ordering; this test pins the implementation's choice.""" + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["headers"] = request.headers + return _json_response() + + adapter = HttpMCPAdapter(transport=httpx.MockTransport(handler)) + + await adapter.relay( + route=MCPResolvedRoute( + url=f"https://{_PUBLIC_IP}/mcp", headers={"X-Shared": "route-value"} + ), + auth=_auth(), + context=_context(), + body=b"{}", + headers={"X-Shared": "caller-value"}, + ) + + assert captured["headers"]["X-Shared"] == "caller-value" + + +@pytest.mark.asyncio +async def test_upstream_status_and_body_relayed_untouched(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={"content-type": "application/json", "x-upstream": "1"}, + content=b'{"jsonrpc": "2.0", "id": 1, "result": {"ok": true}}', + ) + + adapter = HttpMCPAdapter(transport=httpx.MockTransport(handler)) + + result = await adapter.relay( + route=MCPResolvedRoute(url=f"https://{_PUBLIC_IP}/mcp"), + auth=_auth(), + context=_context(), + body=b"{}", + headers={}, + ) + + assert isinstance(result, MCPRelayResult) + assert result.status_code == 200 + assert result.body == b'{"jsonrpc": "2.0", "id": 1, "result": {"ok": true}}' + assert result.headers["x-upstream"] == "1" + + +@pytest.mark.asyncio +async def test_brokered_auth_is_rejected(): + adapter = HttpMCPAdapter(transport=httpx.MockTransport(lambda r: _json_response())) + + with pytest.raises(TypeError): + await adapter.relay( + route=MCPResolvedRoute(url=f"https://{_PUBLIC_IP}/mcp"), + auth=MCPBrokeredAuth.model_construct(connection=SimpleNamespace()), + context=_context(), + body=b"{}", + headers={}, + ) + + +# --------------------------------------------------------------------------- +# Authorization derivation +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_no_authorization_header_when_secret_is_none(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["headers"] = request.headers + return _json_response() + + adapter = HttpMCPAdapter(transport=httpx.MockTransport(handler)) + + await adapter.relay( + route=MCPResolvedRoute(url=f"https://{_PUBLIC_IP}/mcp"), + auth=_auth(secret=None), + context=_context(), + body=b"{}", + headers={}, + ) + + assert "authorization" not in captured["headers"] + + +@pytest.mark.asyncio +async def test_authorization_header_derived_from_oauth_grant(): + """`OAuthGrantSettingsDTO` (entities.md §4.5) isn't in this codebase yet (WP16, + wave 3), so the mock secret is a SimpleNamespace shaped like its future + `.secret.data.grant.{access_token,token_type}` — the shape HttpMCPAdapter reads + defensively via getattr.""" + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["headers"] = request.headers + return _json_response() + + adapter = HttpMCPAdapter(transport=httpx.MockTransport(handler)) + mock_secret = SimpleNamespace( + secret=SimpleNamespace( + data=SimpleNamespace( + grant=SimpleNamespace(access_token="tok-abc123", token_type="Bearer") + ) + ) + ) + + await adapter.relay( + route=MCPResolvedRoute(url=f"https://{_PUBLIC_IP}/mcp"), + auth=MCPDirectAuth.model_construct(secret=mock_secret), + context=_context(), + body=b"{}", + headers={}, + ) + + assert captured["headers"]["authorization"] == "Bearer tok-abc123" + + +# --------------------------------------------------------------------------- +# Transport failure vs. protocol-level (pass-through) failure +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_connection_failure_raises_mcp_upstream_error_with_no_false_status(): + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + adapter = HttpMCPAdapter(transport=httpx.MockTransport(handler)) + + with pytest.raises(MCPUpstreamError) as excinfo: + await adapter.relay( + route=MCPResolvedRoute(url=f"https://{_PUBLIC_IP}/mcp"), + auth=_auth(), + context=_context(), + body=b"{}", + headers={}, + ) + + assert excinfo.value.target == f"https://{_PUBLIC_IP}/mcp" + assert excinfo.value.status_code is None + + +@pytest.mark.asyncio +async def test_jsonrpc_error_body_is_returned_not_raised(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": 1, + "error": {"code": -32602, "message": "unknown tool"}, + }, + ) + + adapter = HttpMCPAdapter(transport=httpx.MockTransport(handler)) + + result = await adapter.relay( + route=MCPResolvedRoute(url=f"https://{_PUBLIC_IP}/mcp"), + auth=_auth(), + context=_context(), + body=b"{}", + headers={}, + ) + + assert result.status_code == 200 + assert b'"error"' in result.body + + +# --------------------------------------------------------------------------- +# SSRF guard (D28) — AGENTA_INSECURE_EGRESS_ALLOWED=false via the autouse fixture above +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "url", + [ + "http://169.254.169.254/latest/meta-data/", # cloud metadata, link-local + "https://127.0.0.1/mcp", # loopback + "https://10.0.0.1/mcp", # RFC-1918 private + ], +) +async def test_blocked_targets_are_refused(url): + adapter = HttpMCPAdapter(transport=httpx.MockTransport(lambda r: _json_response())) + + with pytest.raises(MCPUpstreamError): + await adapter.relay( + route=MCPResolvedRoute(url=url), + auth=_auth(), + context=_context(), + body=b"{}", + headers={}, + ) + + +@pytest.mark.asyncio +async def test_plain_http_public_host_is_refused(): + adapter = HttpMCPAdapter(transport=httpx.MockTransport(lambda r: _json_response())) + + with pytest.raises(MCPUpstreamError): + await adapter.relay( + route=MCPResolvedRoute(url=f"http://{_PUBLIC_IP}/mcp"), + auth=_auth(), + context=_context(), + body=b"{}", + headers={}, + ) + + +@pytest.mark.asyncio +async def test_unresolvable_hostname_gives_resolution_message_not_blocked_message( + monkeypatch, +): + import socket + + monkeypatch.setattr( + "oss.src.core.webhooks.utils.socket.getaddrinfo", + lambda *a, **kw: (_ for _ in ()).throw( + socket.gaierror("Name or service not known") + ), + ) + adapter = HttpMCPAdapter(transport=httpx.MockTransport(lambda r: _json_response())) + + with pytest.raises(MCPUpstreamError) as excinfo: + await adapter.relay( + route=MCPResolvedRoute(url="https://this-does-not-exist.invalid/mcp"), + auth=_auth(), + context=_context(), + body=b"{}", + headers={}, + ) + + assert "could not be resolved" in (excinfo.value.detail or "") + assert "blocked" not in (excinfo.value.detail or "") + + +@pytest.mark.asyncio +async def test_hostname_resolves_to_literal_ip_with_host_header_preserved(monkeypatch): + monkeypatch.setattr( + "oss.src.core.webhooks.utils.socket.getaddrinfo", + lambda *a, **kw: [(None, None, None, None, (_PUBLIC_IP, 0))], + ) + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["host"] = request.url.host + captured["host_header"] = request.headers["host"] + return _json_response() + + adapter = HttpMCPAdapter(transport=httpx.MockTransport(handler)) + + await adapter.relay( + route=MCPResolvedRoute(url="https://mcp.example.com/mcp"), + auth=_auth(), + context=_context(), + body=b"{}", + headers={}, + ) + + assert captured["host"] == _PUBLIC_IP + assert captured["host_header"] == "mcp.example.com" + + +@pytest.mark.asyncio +async def test_host_allowlist_bypasses_the_guard(monkeypatch): + monkeypatch.setattr( + "oss.src.core.gateways.mcps.providers.http.adapter.env.mcp_gateway.host_allowlist", + ["internal-mcp.local"], + ) + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["host"] = request.url.host + return _json_response() + + adapter = HttpMCPAdapter(transport=httpx.MockTransport(handler)) + + result = await adapter.relay( + route=MCPResolvedRoute(url="http://internal-mcp.local/mcp"), + auth=_auth(), + context=_context(), + body=b"{}", + headers={}, + ) + + assert result.status_code == 200 + assert captured["host"] == "internal-mcp.local" + + +@pytest.mark.asyncio +async def test_endpoint_timeout_config_is_respected(monkeypatch): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + return _json_response() + + real_client_init = httpx.AsyncClient.__init__ + + def spy_init(self, *args, **kwargs): + captured["timeout"] = kwargs.get("timeout") + return real_client_init(self, *args, **kwargs) + + monkeypatch.setattr(httpx.AsyncClient, "__init__", spy_init) + + adapter = HttpMCPAdapter(transport=httpx.MockTransport(handler)) + + await adapter.relay( + route=MCPResolvedRoute( + url=f"https://{_PUBLIC_IP}/mcp", + settings=MCPEndpointSettings(timeout_seconds=5.0), + ), + auth=_auth(), + context=_context(), + body=b"{}", + headers={}, + ) + + assert captured["timeout"] == 5.0 + + +# --------------------------------------------------------------------------- +# Namespace scoping: the guard is HttpMCPAdapter's, not MCPUpstreamInterface's. +# The `agenta` namespace routes to MockMCPAdapter (WP5), which never makes an +# outbound call at all, so a private-looking route.url on it is never refused. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_agenta_route_to_a_private_address_is_not_refused(): + adapter = MockMCPAdapter() + + result = await adapter.relay( + route=MCPResolvedRoute(url="http://127.0.0.1/mcp"), + auth=_auth(), + context=MCPCallContext(method="tools/list"), + body=b'{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', + headers={}, + ) + + assert result.status_code == 200 + + +# --------------------------------------------------------------------------- +# Gateway-only headers never reach a third-party server (D31) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_our_credentials_header_is_never_forwarded_upstream(): + """The caller's headers are forwarded wholesale except this one: it authenticates + the caller INTO the gateway and is ours, not the upstream's (D31).""" + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["headers"] = request.headers + return _json_response() + + adapter = HttpMCPAdapter(transport=httpx.MockTransport(handler)) + + await adapter.relay( + route=MCPResolvedRoute(url=f"https://{_PUBLIC_IP}/mcp"), + auth=_auth(secret=None), + context=_context(), + body=b"{}", + headers={"X-AG-Credentials": "Secret leaked-token"}, + ) + + assert "x-ag-credentials" not in captured["headers"] + + +@pytest.mark.asyncio +async def test_caller_authorization_reaches_the_server_when_no_secret_resolved(): + """Pass-through (OD15): the data plane reads only our own header, so `Authorization` + is the caller's and nothing of ours overwrites it.""" + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["headers"] = request.headers + return _json_response() + + adapter = HttpMCPAdapter(transport=httpx.MockTransport(handler)) + + await adapter.relay( + route=MCPResolvedRoute(url=f"https://{_PUBLIC_IP}/mcp"), + auth=_auth(secret=None), + context=_context(), + body=b"{}", + headers={"Authorization": "Bearer caller-token"}, + ) + + assert captured["headers"]["authorization"] == "Bearer caller-token" diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_llm_auth_strategies.py b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_auth_strategies.py new file mode 100644 index 0000000000..76be6fccd8 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_auth_strategies.py @@ -0,0 +1,172 @@ +"""Unit tests for auth.py's per-deployment secret presentation (specs-wp24.md Phase 1). + +`_vertex_auth` mints a token via litellm's Vertex credential helper — patched here so this +stays a unit test (no real Google call, no real LLM call, per the wave's hard rule). +""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from oss.src.core.gateways.llms.dtos import LLMDeploymentKind, LLMResolvedRoute +from oss.src.core.gateways.llms.providers.passthrough.auth import build_auth_headers +from oss.src.core.gateways.llms.types import LLMUpstreamError +from oss.src.core.gateways.policy.dtos import ( + ResolvedSecret, + SecretOrigin, + SecretOwner, + SecretOwnerKind, +) +from oss.src.core.secrets.dtos import ( + CustomProviderDTO, + CustomProviderSettingsDTO, + SecretResponseDTO, + StandardProviderDTO, + StandardProviderSettingsDTO, +) +from oss.src.core.secrets.enums import ( + CustomProviderKind, + SecretKind, + StandardProviderKind, +) +from oss.src.core.shared.dtos import Header + + +def _route(**overrides) -> LLMResolvedRoute: + base = dict( + provider_key="openai", deployment_kind=LLMDeploymentKind.DIRECT, model="gpt-4o" + ) + base.update(overrides) + return LLMResolvedRoute(**base) + + +def _standard_secret(key: str = "sk-standard") -> ResolvedSecret: + return ResolvedSecret( + secret=SecretResponseDTO( + kind=SecretKind.PROVIDER_KEY, + data=StandardProviderDTO( + kind=StandardProviderKind.OPENAI, + provider=StandardProviderSettingsDTO(key=key), + ), + header=Header(name="openai"), + ), + owner=SecretOwner(kind=SecretOwnerKind.PROJECT), + origin=SecretOrigin.VAULT, + ) + + +def _custom_secret(key: str = "sk-custom", extras: dict = None) -> ResolvedSecret: + data = CustomProviderDTO( + kind=CustomProviderKind.CUSTOM, + provider=CustomProviderSettingsDTO(key=key, extras=extras), + models=[], + ).model_dump() + return ResolvedSecret( + secret=SecretResponseDTO( + kind=SecretKind.CUSTOM_PROVIDER, data=data, header=Header(name="c") + ), + owner=SecretOwner(kind=SecretOwnerKind.PROJECT), + origin=SecretOrigin.VAULT, + ) + + +@pytest.mark.asyncio +async def test_direct_default_is_bearer_authorization(): + headers = await build_auth_headers( + _route(provider_key="openai"), _standard_secret("sk-1") + ) + assert headers == {"Authorization": "Bearer sk-1"} + + +@pytest.mark.asyncio +async def test_direct_anthropic_uses_x_api_key_no_prefix(): + headers = await build_auth_headers( + _route(provider_key="anthropic"), _standard_secret("sk-ant") + ) + assert headers == {"x-api-key": "sk-ant"} + + +@pytest.mark.asyncio +async def test_direct_no_secret_returns_no_headers(): + headers = await build_auth_headers(_route(provider_key="openai"), None) + assert headers == {} + + +@pytest.mark.asyncio +async def test_custom_merges_extras_under_bearer_authorization(): + headers = await build_auth_headers( + _route(deployment_kind=LLMDeploymentKind.CUSTOM), + _custom_secret("sk-c", extras={"x-org-id": "org-1"}), + ) + assert headers == {"x-org-id": "org-1", "Authorization": "Bearer sk-c"} + + +@pytest.mark.asyncio +async def test_azure_uses_api_key_header_not_authorization(): + headers = await build_auth_headers( + _route(deployment_kind=LLMDeploymentKind.AZURE), _custom_secret("sk-azure") + ) + assert headers == {"api-key": "sk-azure"} + + +@pytest.mark.asyncio +async def test_azure_with_no_secret_raises(): + with pytest.raises(LLMUpstreamError): + await build_auth_headers(_route(deployment_kind=LLMDeploymentKind.AZURE), None) + + +@pytest.mark.asyncio +async def test_bedrock_uses_bearer_key_from_extras(): + headers = await build_auth_headers( + _route(deployment_kind=LLMDeploymentKind.BEDROCK), + _custom_secret(extras={"aws_bearer_token_bedrock": "bedrock-key"}), + ) + assert headers == {"Authorization": "Bearer bedrock-key"} + + +@pytest.mark.asyncio +async def test_bedrock_with_no_bearer_key_raises(): + with pytest.raises(LLMUpstreamError): + await build_auth_headers( + _route(deployment_kind=LLMDeploymentKind.BEDROCK), + _custom_secret(key=None, extras=None), + ) + + +@pytest.mark.asyncio +async def test_vertex_mints_a_token_via_litellms_credential_helper(): + route = _route( + deployment_kind=LLMDeploymentKind.VERTEX, extras={"vertex_project": "acme"} + ) + secret = _custom_secret( + extras={"vertex_ai_credentials": '{"type": "service_account"}'} + ) + + with patch( + "litellm.llms.vertex_ai.vertex_llm_base.VertexBase.get_access_token_async", + new_callable=AsyncMock, + return_value=("minted-token", "acme"), + ) as mocked: + headers = await build_auth_headers(route, secret) + + assert headers == {"Authorization": "Bearer minted-token"} + mocked.assert_awaited_once_with( + credentials='{"type": "service_account"}', project_id="acme" + ) + + +@pytest.mark.asyncio +async def test_vertex_with_no_credentials_raises_without_minting(): + route = _route( + deployment_kind=LLMDeploymentKind.VERTEX, extras={"vertex_project": "acme"} + ) + with pytest.raises(LLMUpstreamError): + await build_auth_headers(route, _custom_secret(extras=None)) + + +@pytest.mark.asyncio +async def test_sagemaker_always_raises(): + with pytest.raises(LLMUpstreamError): + await build_auth_headers( + _route(deployment_kind=LLMDeploymentKind.SAGEMAKER), _custom_secret() + ) diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_llm_catalog.py b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_catalog.py new file mode 100644 index 0000000000..57bd304d81 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_catalog.py @@ -0,0 +1,39 @@ +"""Unit tests for `catalog.py` (specs-wp7.md, tasks-wp7.md Phase 1). Nothing running.""" + +from agenta.sdk.utils.assets import supported_llm_models + +from oss.src.core.gateways.dtos import GatewayEndpointNamespace +from oss.src.core.gateways.llms.catalog import ( + standard_llm_endpoint, + standard_llm_endpoints, +) +from oss.src.core.gateways.llms.dtos import LLMDeploymentKind + + +def test_standard_llm_endpoint_openai_matches_the_catalogue_exactly(): + endpoint = standard_llm_endpoint(provider_key="openai") + + assert endpoint is not None + assert endpoint.namespace == GatewayEndpointNamespace.STANDARD + assert endpoint.slug == "openai" + assert endpoint.deployment_kind == LLMDeploymentKind.DIRECT + assert endpoint.data.models.allowlist == supported_llm_models["openai"] + assert endpoint.id is None + assert endpoint.created_at is None + + +def test_standard_llm_endpoint_none_for_uncatalogued_standard_providers(): + for provider_key in ("anyscale", "alephalpha", "mistralai"): + assert standard_llm_endpoint(provider_key=provider_key) is None + + +def test_standard_llm_endpoint_none_for_unknown_provider(): + assert standard_llm_endpoint(provider_key="not-a-provider") is None + + +def test_standard_llm_endpoints_returns_exactly_eleven(): + endpoints = standard_llm_endpoints() + assert len(endpoints) == 11 + assert {endpoint.provider_key for endpoint in endpoints} == set( + supported_llm_models.keys() + ) diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_llm_models.py b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_models.py new file mode 100644 index 0000000000..05cadb14b0 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_models.py @@ -0,0 +1,100 @@ +"""Wire model instantiation — apis/fastapi/gateways/llms/models.py (entities.md §6). + +C0-style: every model in the file constructs with representative values. +""" + +from uuid import uuid4 + +from oss.src.apis.fastapi.gateways.llms.models import ( + LLMEndpointCreateRequest, + LLMEndpointEditRequest, + LLMEndpointQueryRequest, + LLMEndpointResponse, + LLMEndpointsResponse, +) +from oss.src.core.gateways.llms.dtos import ( + LLMDeploymentKind, + LLMEndpoint, + LLMEndpointCreate, + LLMEndpointData, + LLMEndpointEdit, + LLMEndpointQuery, + LLMModelFilter, +) +from oss.src.core.shared.dtos import Windowing + + +def _endpoint_create() -> LLMEndpointCreate: + return LLMEndpointCreate( + slug="acme-openai", + provider_key="openai", + deployment_kind=LLMDeploymentKind.DIRECT, + data=LLMEndpointData(models=LLMModelFilter(allowlist=["gpt-4o"])), + ) + + +def _endpoint() -> LLMEndpoint: + return LLMEndpoint( + id=uuid4(), + slug="acme-openai", + provider_key="openai", + deployment_kind=LLMDeploymentKind.DIRECT, + data=LLMEndpointData(models=LLMModelFilter(allowlist=["gpt-4o"])), + ) + + +def test_llm_endpoint_create_request_instantiates(): + request = LLMEndpointCreateRequest(endpoint=_endpoint_create()) + assert request.endpoint.provider_key == "openai" + + +def test_llm_endpoint_edit_request_instantiates(): + request = LLMEndpointEditRequest( + endpoint=LLMEndpointEdit( + id=uuid4(), + data=LLMEndpointData(models=LLMModelFilter(allowlist=["gpt-4o-mini"])), + ) + ) + assert request.endpoint.data.models.allowlist == ["gpt-4o-mini"] + + +def test_llm_endpoint_query_request_instantiates_with_defaults(): + request = LLMEndpointQueryRequest() + assert request.endpoint is None + assert request.windowing is None + + +def test_llm_endpoint_query_request_instantiates_with_values(): + request = LLMEndpointQueryRequest( + endpoint=LLMEndpointQuery(provider_key="openai"), + windowing=Windowing(limit=10), + ) + assert request.endpoint.provider_key == "openai" + assert request.windowing.limit == 10 + + +def test_llm_endpoint_response_instantiates(): + response = LLMEndpointResponse(count=1, endpoint=_endpoint()) + assert response.count == 1 + assert response.endpoint.slug == "acme-openai" + + +def test_llm_endpoint_response_instantiates_with_defaults(): + response = LLMEndpointResponse() + assert response.count == 0 + assert response.endpoint is None + + +def test_llm_endpoints_response_instantiates(): + response = LLMEndpointsResponse(count=1, endpoints=[_endpoint()]) + assert response.count == 1 + assert len(response.endpoints) == 1 + + +def test_llm_endpoints_response_default_list_is_not_shared(): + """`Field(default_factory=list)`, not a bare `[]` — mutating one instance's + default must not leak into a sibling instance's default.""" + first = LLMEndpointsResponse() + second = LLMEndpointsResponse() + first.endpoints.append(_endpoint()) + assert second.endpoints == [] diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_llm_no_body_conversion.py b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_no_body_conversion.py new file mode 100644 index 0000000000..76c190f45d --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_no_body_conversion.py @@ -0,0 +1,57 @@ +"""Guard test for D34 (specs-wp24.md): no code path in the LLM plane parses a request body +except to read the policy fields. A grep-style guard, deliberately — this is the invariant a +future edit is most likely to break quietly (tasks-wp24.md Phase 2). + +Every `json.loads(` call site under `core/gateways/llms/` is enumerated and justified below; +a new one anywhere else fails the test rather than silently reintroducing conversion. +""" + +import re +from pathlib import Path + +_LLM_PLANE_ROOT = Path(__file__).parents[4] / "src/core/gateways/llms" + +# file (relative to _LLM_PLANE_ROOT) -> why its json.loads calls are not body conversion. +_ALLOWED = { + # The policy parse itself: model/stream/ceiling extraction, read-only (D33, D34). + "service.py", + # Reads the RESPONSE body to lift `usage` for the audit record; the bytes yielded to + # the caller are the bytes received, never reconstructed from this parse (D34). + "providers/passthrough/adapter.py", + # The mock is a test double, not a relay — it never forwards bytes anywhere, so + # parsing its own input to fabricate a reply is not the conversion D34 forbids. + "providers/mock/adapter.py", + "providers/mock/app.py", + # D40's carve-out: a literal per-deployment table (Vertex only, OD19), applied only + # on the Messages door. Not conversion — nothing here is read to decide anything. + "providers/passthrough/static_fields.py", +} + +_JSON_LOADS = re.compile(r"\bjson\.loads\(") + + +def test_no_unexpected_json_loads_in_the_llm_plane(): + offenders = [] + for path in _LLM_PLANE_ROOT.rglob("*.py"): + relative = path.relative_to(_LLM_PLANE_ROOT).as_posix() + if not _JSON_LOADS.search(path.read_text()): + continue + if relative not in _ALLOWED: + offenders.append(relative) + + assert not offenders, ( + f"json.loads found outside the allowed set: {offenders}. " + "A new request-body parse in the LLM plane is the body conversion D34 forbids — " + "add it to _ALLOWED only if it reads a RESPONSE body or the policy fields." + ) + + +def test_every_allowed_file_still_exists_and_still_parses_json(): + """The inverse check: an entry here that stops calling json.loads (e.g. a rewrite that + drops the usage-extraction path) is worth noticing, not silently trusting.""" + for relative in _ALLOWED: + path = _LLM_PLANE_ROOT / relative + assert path.exists(), f"{relative} no longer exists — prune it from _ALLOWED" + assert _JSON_LOADS.search(path.read_text()), ( + f"{relative} no longer calls json.loads — prune it from _ALLOWED" + ) diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_llm_proxy.py b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_proxy.py new file mode 100644 index 0000000000..84b662720e --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_proxy.py @@ -0,0 +1,601 @@ +"""Unit tests for LLMGatewayProxy (entities.md §9, workstreams/specs-wp6.md). + +Nothing running: the proxy is exercised against a hand-written mock `LLMGatewayService` +(not WP5's fixture — this is the proxy in isolation) and a Starlette `Request` built from a +raw ASGI scope, no HTTP server. +""" + +import json +from contextlib import contextmanager +from typing import Any, AsyncIterator, Dict, List, Optional +from uuid import uuid4 + +import pytest +from starlette.requests import Request + +from oss.src.apis.fastapi.gateways.llms.proxy import LLMGatewayProxy +from oss.src.core.gateways.dtos import GatewayEndpointNamespace +from oss.src.core.gateways.llms.dtos import LLMProtocol +from oss.src.core.gateways.llms.interfaces import LLMRelayResult +from oss.src.core.gateways.llms.types import ( + LLMEndpointNotFoundError, + LLMModelNotAllowedError, + LLMUpstreamError, +) +from oss.src.core.access.permissions.types import Permission +from oss.src.core.gateways.policy.dtos import SecretMode, SecretOwnerKind +from oss.src.core.gateways.policy.types import ( + CeilingExceededError, + SecretInvalidError, + SecretNotFoundError, + EntitlementDeniedError, + PolicyDeniedError, +) +from oss.src.utils.context import ( + AuthContext, + AuthScope, + SecretCredentials, + reset_auth_context, + set_auth_context, +) + + +@contextmanager +def _auth_scope(): + scope = AuthScope( + organization_id=uuid4(), + workspace_id=uuid4(), + project_id=uuid4(), + user_id=uuid4(), + ) + token = set_auth_context( + AuthContext(credentials=SecretCredentials(value="test-token"), scope=scope) + ) + try: + yield scope + finally: + reset_auth_context(token) + + +def _request(*, body: bytes, headers: Optional[Dict[str, str]] = None) -> Request: + headers = headers or {} + raw_headers = [(k.lower().encode(), v.encode()) for k, v in headers.items()] + scope = {"type": "http", "method": "POST", "path": "/", "headers": raw_headers} + + async def receive() -> Dict[str, Any]: + return {"type": "http.request", "body": body, "more_body": False} + + return Request(scope, receive) + + +def _body(*, model: str = "gpt-4o", stream: bool = False) -> bytes: + return json.dumps({"model": model, "stream": stream, "messages": []}).encode() + + +def _responses_body(*, model: str = "gpt-4o", stream: bool = False) -> bytes: + return json.dumps({"model": model, "stream": stream, "input": []}).encode() + + +def _messages_body(*, model: str = "claude-3", stream: bool = False) -> bytes: + return json.dumps( + {"model": model, "stream": stream, "messages": [], "max_tokens": 1024} + ).encode() + + +async def _chunks(*items: bytes) -> AsyncIterator[bytes]: + for item in items: + yield item + + +def _relay_result( + *, + status_code: int = 200, + chunks: List[bytes], + headers: Optional[Dict[str, str]] = None, +) -> LLMRelayResult: + return LLMRelayResult( + status_code=status_code, headers=headers or {}, body=_chunks(*chunks) + ) + + +class _MockLlmGatewayService: + def __init__( + self, + *, + relay_result: Optional[LLMRelayResult] = None, + relay_exception: Optional[Exception] = None, + models: Optional[List[str]] = None, + models_exception: Optional[Exception] = None, + ) -> None: + self._relay_result = relay_result + self._relay_exception = relay_exception + self._models = models or [] + self._models_exception = models_exception + self.relay_calls: List[Dict[str, Any]] = [] + self.list_models_calls: List[Dict[str, Any]] = [] + + async def relay_chat_completion( + self, *, scope, namespace, name, body, headers, protocol=None + ) -> LLMRelayResult: + self.relay_calls.append( + { + "scope": scope, + "namespace": namespace, + "name": name, + "body": body, + "headers": headers, + "protocol": protocol, + } + ) + if self._relay_exception is not None: + raise self._relay_exception + assert self._relay_result is not None + return self._relay_result + + async def list_models(self, *, scope, namespace, name) -> List[str]: + self.list_models_calls.append( + {"scope": scope, "namespace": namespace, "name": name} + ) + if self._models_exception is not None: + raise self._models_exception + return self._models + + +async def _read_body(response) -> bytes: + if hasattr(response, "body_iterator"): + return b"".join([chunk async for chunk in response.body_iterator]) + return response.body + + +# --- successful relay --------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_non_streaming_call_returns_single_chunk_verbatim(): + chunk = json.dumps({"id": "x", "choices": []}).encode() + service = _MockLlmGatewayService( + relay_result=_relay_result(status_code=200, chunks=[chunk]) + ) + proxy = LLMGatewayProxy(llm_gateway_service=service) + + with _auth_scope(): + response = await proxy.chat_completions_custom( + _request(body=_body(stream=False)), "my-slug" + ) + + assert response.status_code == 200 + assert await _read_body(response) == chunk + + +@pytest.mark.asyncio +async def test_streaming_call_passes_body_through_streaming_response_untouched(): + frames = [b"data: one\n\n", b"data: two\n\n", b"data: [DONE]\n\n"] + service = _MockLlmGatewayService( + relay_result=_relay_result(status_code=200, chunks=frames) + ) + proxy = LLMGatewayProxy(llm_gateway_service=service) + + with _auth_scope(): + response = await proxy.chat_completions_custom( + _request(body=_body(stream=True)), "my-slug" + ) + + assert response.media_type == "text/event-stream" + assert await _read_body(response) == b"".join(frames) + + +@pytest.mark.asyncio +async def test_standard_route_passes_standard_namespace_and_provider_as_name(): + service = _MockLlmGatewayService( + relay_result=_relay_result(status_code=200, chunks=[b"{}"]) + ) + proxy = LLMGatewayProxy(llm_gateway_service=service) + + with _auth_scope(): + await proxy.chat_completions_standard(_request(body=_body()), "openai") + + assert service.relay_calls[0]["namespace"] == GatewayEndpointNamespace.STANDARD + assert service.relay_calls[0]["name"] == "openai" + + +@pytest.mark.asyncio +async def test_custom_route_passes_custom_namespace_and_slug_as_name(): + service = _MockLlmGatewayService( + relay_result=_relay_result(status_code=200, chunks=[b"{}"]) + ) + proxy = LLMGatewayProxy(llm_gateway_service=service) + + with _auth_scope(): + await proxy.chat_completions_custom(_request(body=_body()), "my-slug") + + assert service.relay_calls[0]["namespace"] == GatewayEndpointNamespace.CUSTOM + assert service.relay_calls[0]["name"] == "my-slug" + + +@pytest.mark.asyncio +async def test_inbound_authorization_header_is_stripped_before_the_service_call(): + service = _MockLlmGatewayService( + relay_result=_relay_result(status_code=200, chunks=[b"{}"]) + ) + proxy = LLMGatewayProxy(llm_gateway_service=service) + + with _auth_scope(): + await proxy.chat_completions_custom( + _request(body=_body(), headers={"Authorization": "Secret caller-token"}), + "my-slug", + ) + + assert "authorization" not in {k.lower() for k in service.relay_calls[0]["headers"]} + + +@pytest.mark.asyncio +async def test_response_headers_drop_upstreams_stale_content_length(): + result = _relay_result( + status_code=200, + chunks=[b"hello world"], + headers={"content-length": "999999", "x-upstream": "yes"}, + ) + service = _MockLlmGatewayService(relay_result=result) + proxy = LLMGatewayProxy(llm_gateway_service=service) + + with _auth_scope(): + response = await proxy.chat_completions_custom( + _request(body=_body(stream=False)), "my-slug" + ) + + assert response.headers["content-length"] == str(len(b"hello world")) + assert response.headers["x-upstream"] == "yes" + + +# --- routing / validation failures before the service is ever called --------- # + + +@pytest.mark.asyncio +async def test_missing_model_returns_openai_invalid_request_error_without_calling_service(): + service = _MockLlmGatewayService() + proxy = LLMGatewayProxy(llm_gateway_service=service) + body = json.dumps({"messages": []}).encode() + + with _auth_scope(): + response = await proxy.chat_completions_custom(_request(body=body), "my-slug") + + assert response.status_code == 400 + payload = json.loads(response.body) + assert payload["error"]["code"] == "invalid_request" + assert service.relay_calls == [] + + +# --- OpenAI-shaped denial mapping, one case per documented exception --------- # + + +_DENIAL_CASES = [ + ( + PolicyDeniedError(permission=Permission.USE_LLM_ENDPOINTS, target="t"), + 403, + "policy_denied", + ), + (EntitlementDeniedError(key="k", target="t"), 403, "policy_denied"), + ( + LLMModelNotAllowedError( + model="gpt-4o", namespace=GatewayEndpointNamespace.CUSTOM, name="my-slug" + ), + 403, + "model_not_allowed", + ), + ( + CeilingExceededError( + ceiling="max_output_tokens", requested=1000, allowed=100, target="t" + ), + 400, + "ceiling_exceeded", + ), + ( + SecretNotFoundError( + mode=SecretMode.PROJECT_ONLY, + missing=SecretOwnerKind.PROJECT, + target="t", + ), + 409, + "secret_missing", + ), + ( + SecretInvalidError(target="t", detail="revoked"), + 409, + "secret_invalid", + ), + ( + LLMEndpointNotFoundError( + namespace=GatewayEndpointNamespace.CUSTOM, name="my-slug" + ), + 404, + "endpoint_not_found", + ), + ( + LLMUpstreamError(provider_key="openai", status_code=503, detail="down"), + 502, + "upstream_error", + ), + ( + LLMUpstreamError(provider_key="openai", status_code=400, detail="bad"), + 424, + "upstream_error", + ), + ( + LLMUpstreamError(provider_key="openai", status_code=None, detail="timed out"), + 424, + "upstream_error", + ), +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("exc,expected_status,expected_code", _DENIAL_CASES) +async def test_domain_exception_maps_to_openai_shaped_denial( + exc, expected_status, expected_code +): + service = _MockLlmGatewayService(relay_exception=exc) + proxy = LLMGatewayProxy(llm_gateway_service=service) + + with _auth_scope(): + response = await proxy.chat_completions_custom( + _request(body=_body()), "my-slug" + ) + + assert response.status_code == expected_status + payload = json.loads(response.body) + assert set(payload.keys()) == {"error"} + assert payload["error"]["code"] == expected_code + assert "count" not in payload # the house envelope never leaks onto this surface + + +@pytest.mark.asyncio +@pytest.mark.parametrize("exc,expected_status,expected_code", _DENIAL_CASES) +async def test_typed_denials_carry_the_code_marker_in_message_except_upstream_error( + exc, expected_status, expected_code +): + """WP25/OD18: `code` must survive in `message` alone, because Codex's own SDK + (codex-rs's `extract_error_message`) discards every other field. `upstream_error` + is excluded — D16 forwards the upstream's own detail untouched.""" + service = _MockLlmGatewayService(relay_exception=exc) + proxy = LLMGatewayProxy(llm_gateway_service=service) + + with _auth_scope(): + response = await proxy.chat_completions_custom( + _request(body=_body()), "my-slug" + ) + + message = json.loads(response.body)["error"]["message"] + marker = f"⟦agenta_code:{expected_code}⟧" + if expected_code == "upstream_error": + assert marker not in message + else: + assert message.endswith(marker) + + +@pytest.mark.asyncio +async def test_ceiling_exceeded_names_the_three_values(): + exc = CeilingExceededError( + ceiling="max_output_tokens", requested=1000, allowed=100, target="t" + ) + service = _MockLlmGatewayService(relay_exception=exc) + proxy = LLMGatewayProxy(llm_gateway_service=service) + + with _auth_scope(): + response = await proxy.chat_completions_custom( + _request(body=_body()), "my-slug" + ) + + error = json.loads(response.body)["error"] + assert error["ceiling"] == "max_output_tokens" + assert error["requested"] == 1000 + assert error["allowed"] == 100 + + +# --- list_models --------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_list_models_standard_shapes_the_openai_list_body(): + service = _MockLlmGatewayService(models=["gpt-4o", "gpt-4o-mini"]) + proxy = LLMGatewayProxy(llm_gateway_service=service) + + with _auth_scope(): + result = await proxy.list_models_standard("openai") + + assert result == { + "object": "list", + "data": [ + {"id": "gpt-4o", "object": "model"}, + {"id": "gpt-4o-mini", "object": "model"}, + ], + } + assert ( + service.list_models_calls[0]["namespace"] == GatewayEndpointNamespace.STANDARD + ) + assert service.list_models_calls[0]["name"] == "openai" + + +@pytest.mark.asyncio +async def test_list_models_custom_shapes_the_openai_list_body(): + service = _MockLlmGatewayService(models=["mock/echo"]) + proxy = LLMGatewayProxy(llm_gateway_service=service) + + with _auth_scope(): + result = await proxy.list_models_custom("my-slug") + + assert result == { + "object": "list", + "data": [{"id": "mock/echo", "object": "model"}], + } + assert service.list_models_calls[0]["namespace"] == GatewayEndpointNamespace.CUSTOM + assert service.list_models_calls[0]["name"] == "my-slug" + + +@pytest.mark.asyncio +async def test_list_models_maps_domain_exception_too(): + exc = LLMEndpointNotFoundError( + namespace=GatewayEndpointNamespace.CUSTOM, name="missing-slug" + ) + service = _MockLlmGatewayService(models_exception=exc) + proxy = LLMGatewayProxy(llm_gateway_service=service) + + with _auth_scope(): + response = await proxy.list_models_custom("missing-slug") + + assert response.status_code == 404 + assert json.loads(response.body)["error"]["code"] == "endpoint_not_found" + + +# --- the responses and messages doors (D33, WP23) ------------------------------ # +# +# Same behavior as chat_completions above, exercised per door: the route reaches the +# handler, the context carries the right model/stream/protocol, and the body passes +# through untouched. Nothing below the handler branches on protocol, so one +# parametrized set covers all three doors without three copies of the same assertions. + +_DOORS = [ + ( + "chat_completions_standard", + "chat_completions_custom", + _body, + LLMProtocol.CHAT_COMPLETIONS, + ), + ( + "responses_standard", + "responses_custom", + _responses_body, + LLMProtocol.RESPONSES, + ), + ( + "messages_standard", + "messages_custom", + _messages_body, + LLMProtocol.MESSAGES, + ), +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("standard_handler,custom_handler,body_fn,protocol", _DOORS) +async def test_door_standard_route_passes_namespace_provider_and_protocol( + standard_handler, custom_handler, body_fn, protocol +): + service = _MockLlmGatewayService( + relay_result=_relay_result(status_code=200, chunks=[b"{}"]) + ) + proxy = LLMGatewayProxy(llm_gateway_service=service) + handler = getattr(proxy, standard_handler) + + with _auth_scope(): + await handler(_request(body=body_fn()), "openai") + + call = service.relay_calls[0] + assert call["namespace"] == GatewayEndpointNamespace.STANDARD + assert call["name"] == "openai" + assert call["protocol"] == protocol + + +@pytest.mark.asyncio +@pytest.mark.parametrize("standard_handler,custom_handler,body_fn,protocol", _DOORS) +async def test_door_custom_route_passes_namespace_slug_and_protocol( + standard_handler, custom_handler, body_fn, protocol +): + service = _MockLlmGatewayService( + relay_result=_relay_result(status_code=200, chunks=[b"{}"]) + ) + proxy = LLMGatewayProxy(llm_gateway_service=service) + handler = getattr(proxy, custom_handler) + + with _auth_scope(): + await handler(_request(body=body_fn()), "my-slug") + + call = service.relay_calls[0] + assert call["namespace"] == GatewayEndpointNamespace.CUSTOM + assert call["name"] == "my-slug" + assert call["protocol"] == protocol + + +@pytest.mark.asyncio +@pytest.mark.parametrize("standard_handler,custom_handler,body_fn,protocol", _DOORS) +async def test_door_body_relays_byte_for_byte( + standard_handler, custom_handler, body_fn, protocol +): + raw_body = body_fn(stream=False) + service = _MockLlmGatewayService( + relay_result=_relay_result(status_code=200, chunks=[b"{}"]) + ) + proxy = LLMGatewayProxy(llm_gateway_service=service) + handler = getattr(proxy, custom_handler) + + with _auth_scope(): + await handler(_request(body=raw_body), "my-slug") + + assert service.relay_calls[0]["body"] == raw_body + + +@pytest.mark.asyncio +@pytest.mark.parametrize("standard_handler,custom_handler,body_fn,protocol", _DOORS) +async def test_door_streaming_flag_drives_the_response_shape( + standard_handler, custom_handler, body_fn, protocol +): + frames = [b"data: one\n\n", b"data: [DONE]\n\n"] + service = _MockLlmGatewayService( + relay_result=_relay_result(status_code=200, chunks=frames) + ) + proxy = LLMGatewayProxy(llm_gateway_service=service) + handler = getattr(proxy, custom_handler) + + with _auth_scope(): + response = await handler(_request(body=body_fn(stream=True)), "my-slug") + + assert response.media_type == "text/event-stream" + assert await _read_body(response) == b"".join(frames) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("standard_handler,custom_handler,body_fn,protocol", _DOORS) +async def test_door_missing_model_returns_invalid_request_without_calling_service( + standard_handler, custom_handler, body_fn, protocol +): + service = _MockLlmGatewayService() + proxy = LLMGatewayProxy(llm_gateway_service=service) + handler = getattr(proxy, custom_handler) + body = json.dumps({"stream": False}).encode() + + with _auth_scope(): + response = await handler(_request(body=body), "my-slug") + + assert response.status_code == 400 + assert json.loads(response.body)["error"]["code"] == "invalid_request" + assert service.relay_calls == [] + + +# --- the route table (a door added without a test is a door nobody knows about) --- # + + +def test_route_table_matches_the_design_exactly(): + proxy = LLMGatewayProxy(llm_gateway_service=_MockLlmGatewayService()) + + actual = {} + for route in proxy.router.routes: + for method in route.methods: + if method == "HEAD": + continue + actual[(route.path, method)] = route.operation_id + + assert actual == { + ( + "/standard/{provider}/v1/chat/completions", + "POST", + ): "llm_gateway_chat_completions_standard", + ( + "/custom/{slug}/v1/chat/completions", + "POST", + ): "llm_gateway_chat_completions_custom", + ("/standard/{provider}/v1/responses", "POST"): "llm_gateway_responses_standard", + ("/custom/{slug}/v1/responses", "POST"): "llm_gateway_responses_custom", + ("/standard/{provider}/v1/messages", "POST"): "llm_gateway_messages_standard", + ("/custom/{slug}/v1/messages", "POST"): "llm_gateway_messages_custom", + ("/standard/{provider}/v1/models", "GET"): "llm_gateway_list_models_standard", + ("/custom/{slug}/v1/models", "GET"): "llm_gateway_list_models_custom", + } diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_llm_proxy_utils.py b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_proxy_utils.py new file mode 100644 index 0000000000..1d14cd264e --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_proxy_utils.py @@ -0,0 +1,125 @@ +"""Unit tests for the front-door parsers (entities.md §9, D33, workstreams/specs-wp23.md). + +Nothing running: each parser is exercised as a plain function over bytes. +""" + +import json + +import pytest + +from oss.src.core.gateways.llms.dtos import LLMCallContext, LLMProtocol +from oss.src.apis.fastapi.gateways.llms.utils import ( + parse_llm_call_context, + parse_messages_call_context, + parse_responses_call_context, +) + + +def _encode(payload: dict) -> bytes: + return json.dumps(payload).encode() + + +def test_streaming_body_extracts_model_and_stream(): + body = _encode( + { + "model": "gpt-4o", + "stream": True, + "messages": [{"role": "user", "content": "hi"}], + } + ) + + context = parse_llm_call_context(body=body) + + assert context == LLMCallContext(model="gpt-4o", stream=True) + + +def test_non_streaming_body_defaults_stream_false(): + body = _encode({"model": "gpt-4o", "messages": []}) + + context = parse_llm_call_context(body=body) + + assert context == LLMCallContext(model="gpt-4o", stream=False) + + +def test_missing_model_raises_value_error(): + body = _encode({"stream": True, "messages": []}) + + with pytest.raises(ValueError): + parse_llm_call_context(body=body) + + +def test_empty_model_raises_value_error(): + body = _encode({"model": "", "messages": []}) + + with pytest.raises(ValueError): + parse_llm_call_context(body=body) + + +def test_malformed_json_raises_value_error(): + with pytest.raises(ValueError): + parse_llm_call_context(body=b"{not json") + + +def test_does_not_mutate_or_wrap_input_bytes(): + original = _encode({"model": "gpt-4o", "stream": True}) + body = bytes(original) + + context = parse_llm_call_context(body=body) + + assert body == original + assert context.model == "gpt-4o" + + +def test_chat_completions_context_is_tagged_with_its_protocol(): + context = parse_llm_call_context(body=_encode({"model": "gpt-4o"})) + + assert context.protocol == LLMProtocol.CHAT_COMPLETIONS + + +# --- parse_responses_call_context, parse_messages_call_context (D33, WP23) --------- # + +_DOOR_PARSERS = [ + (parse_responses_call_context, LLMProtocol.RESPONSES), + (parse_messages_call_context, LLMProtocol.MESSAGES), +] + + +@pytest.mark.parametrize("parser,protocol", _DOOR_PARSERS) +def test_door_parser_extracts_model_and_stream_and_tags_its_protocol(parser, protocol): + body = _encode({"model": "claude-3", "stream": True, "input": []}) + + context = parser(body=body) + + assert context == LLMCallContext(model="claude-3", stream=True, protocol=protocol) + + +@pytest.mark.parametrize("parser,protocol", _DOOR_PARSERS) +def test_door_parser_defaults_stream_false(parser, protocol): + body = _encode({"model": "claude-3"}) + + context = parser(body=body) + + assert context.stream is False + assert context.protocol == protocol + + +@pytest.mark.parametrize("parser", [p for p, _ in _DOOR_PARSERS]) +def test_door_parser_missing_model_raises_value_error(parser): + with pytest.raises(ValueError): + parser(body=_encode({"stream": True})) + + +@pytest.mark.parametrize("parser", [p for p, _ in _DOOR_PARSERS]) +def test_door_parser_malformed_json_raises_value_error(parser): + with pytest.raises(ValueError): + parser(body=b"{not json") + + +@pytest.mark.parametrize("parser", [p for p, _ in _DOOR_PARSERS]) +def test_door_parser_does_not_mutate_input_bytes(parser): + original = _encode({"model": "claude-3", "stream": True}) + body = bytes(original) + + parser(body=body) + + assert body == original diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_llm_registry.py b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_registry.py new file mode 100644 index 0000000000..ff8b6ea015 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_registry.py @@ -0,0 +1,74 @@ +"""Unit tests for `registry.py` (specs-wp24.md, tasks-wp24.md Phase 1). Nothing running.""" + +import ast +from pathlib import Path + +import pytest +from agenta.sdk.utils.assets import supported_llm_models + +from oss.src.core.gateways.llms.dtos import LLMDeploymentKind +from oss.src.core.gateways.llms.interfaces import LLMRelayResult, LLMUpstreamInterface +from oss.src.core.gateways.llms.registry import LLMUpstreamRegistry, select_upstream +from oss.src.core.gateways.llms.types import LLMAdapterNotFoundError + + +@pytest.mark.parametrize("deployment_kind", list(LLMDeploymentKind)) +def test_mock_deployment_kind_always_selects_mock(deployment_kind): + expected = "mock" if deployment_kind == LLMDeploymentKind.MOCK else "relay" + assert select_upstream("anything", deployment_kind) == expected + + +@pytest.mark.parametrize("provider_key", ["openai", "anthropic", "mock", None, "acme"]) +def test_provider_key_never_changes_the_answer_except_via_deployment_kind(provider_key): + """D34 removed the one branch that read `provider_key` on a stored row + (entities.md §2.4) — the whole decision is `deployment_kind` now.""" + for deployment_kind in LLMDeploymentKind: + expected = "mock" if deployment_kind == LLMDeploymentKind.MOCK else "relay" + assert select_upstream(provider_key, deployment_kind) == expected + + +def test_select_upstream_imports_nothing_beyond_llm_deployment_kind(): + """Pure — no DAO, no vault, no I/O (specs-wp24.md's own contract).""" + module_path = ( + Path(__file__).parents[4] / "src/core/gateways/llms/registry.py" + ).resolve() + tree = ast.parse(module_path.read_text()) + imported_modules = { + alias.name + for node in ast.walk(tree) + if isinstance(node, ast.Import) + for alias in node.names + } | { + node.module + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module + } + forbidden = {"httpx", "litellm"} + assert not (imported_modules & forbidden) + assert not any("dao" in module.lower() for module in imported_modules) + + +class _StubAdapter(LLMUpstreamInterface): + async def relay_chat_completion(self, **kwargs) -> LLMRelayResult: # noqa: ARG002 + raise NotImplementedError + + +def test_registry_get_raises_on_a_miss(): + registry = LLMUpstreamRegistry(adapters={}) + with pytest.raises(LLMAdapterNotFoundError): + registry.get("relay") + + +def test_registry_get_and_keys_roundtrip(): + adapter = _StubAdapter() + registry = LLMUpstreamRegistry(adapters={"relay": adapter}) + assert registry.get("relay") is adapter + assert registry.keys() == ["relay"] + + +def test_every_catalogued_direct_provider_resolves_to_relay(): + """No provider in `supported_llm_models` is unreachable at the select_upstream layer — + OD16 cleared them all; a provider absent from `routing.py`'s table (none, today) would + fail at relay time instead, with the reason named.""" + for provider_key in supported_llm_models: + assert select_upstream(provider_key, LLMDeploymentKind.DIRECT) == "relay" diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_llm_relay_adapter.py b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_relay_adapter.py new file mode 100644 index 0000000000..739d103e8b --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_relay_adapter.py @@ -0,0 +1,663 @@ +"""Unit tests for RelayLLMAdapter (entities.md §7.1, workstreams/specs-wp24.md). + +Nothing running: httpx.MockTransport intercepts every request, no real socket. +""" + +import json +from typing import Optional +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from oss.src.core.gateways.llms.dtos import ( + LLMCallContext, + LLMDeploymentKind, + LLMEndpointSettings, + LLMProtocol, + LLMResolvedRoute, +) +from oss.src.core.gateways.llms.providers.passthrough.adapter import ( + RelayLLMAdapter, +) +from oss.src.core.gateways.llms.types import LLMUpstreamError +from oss.src.core.gateways.policy.dtos import ( + SecretOwner, + SecretOwnerKind, + ResolvedSecret, + SecretOrigin, +) +from oss.src.core.secrets.dtos import ( + CustomProviderDTO, + CustomProviderSettingsDTO, + SecretResponseDTO, + StandardProviderDTO, + StandardProviderSettingsDTO, +) +from oss.src.core.secrets.enums import ( + CustomProviderKind, + SecretKind, + StandardProviderKind, +) +from oss.src.core.shared.dtos import Header + + +def _route( + *, + base_url: Optional[str] = "https://upstream.example/v1", + timeout_seconds: Optional[float] = None, +) -> LLMResolvedRoute: + return LLMResolvedRoute( + provider_key="openai", + deployment_kind=LLMDeploymentKind.CUSTOM, + model="gpt-4o", + base_url=base_url, + headers={"x-route": "1"}, + settings=LLMEndpointSettings(timeout_seconds=timeout_seconds), + ) + + +def _context(*, stream: bool = False) -> LLMCallContext: + return LLMCallContext(model="gpt-4o", stream=stream) + + +def _body() -> bytes: + return json.dumps( + {"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]} + ).encode() + + +def _standard_secret(key: str = "sk-standard") -> ResolvedSecret: + return ResolvedSecret( + secret=SecretResponseDTO( + kind=SecretKind.PROVIDER_KEY, + data=StandardProviderDTO( + kind=StandardProviderKind.OPENAI, + provider=StandardProviderSettingsDTO(key=key), + ), + header=Header(name="openai"), + ), + owner=SecretOwner(kind=SecretOwnerKind.PROJECT), + origin=SecretOrigin.VAULT, + ) + + +def _custom_secret( + key: str = "sk-custom", extras: Optional[dict] = None +) -> ResolvedSecret: + # SecretResponseDTO's own before-validator calls `.get()` on `data` ahead of + # SecretDTO's model-instance-to-dict coercion, so a raw dict here (rather + # than a CustomProviderDTO instance) sidesteps that ordering entirely. + data = CustomProviderDTO( + kind=CustomProviderKind.CUSTOM, + provider=CustomProviderSettingsDTO(key=key, extras=extras), + models=[], + ).model_dump() + return ResolvedSecret( + secret=SecretResponseDTO( + kind=SecretKind.CUSTOM_PROVIDER, + data=data, + header=Header(name="my-custom"), + ), + owner=SecretOwner(kind=SecretOwnerKind.PROJECT), + origin=SecretOrigin.VAULT, + ) + + +def _adapter(handler) -> RelayLLMAdapter: + transport = httpx.MockTransport(handler) + return RelayLLMAdapter(client=httpx.AsyncClient(transport=transport)) + + +async def _drain(body): + return [chunk async for chunk in body] + + +@pytest.mark.asyncio +async def test_standard_provider_secret_injects_bearer_header(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"id": "x", "choices": []}) + + adapter = _adapter(handler) + await adapter.relay_chat_completion( + route=_route(), + secret=_standard_secret("sk-standard"), + context=_context(), + body=_body(), + headers={}, + ) + + assert captured["request"].headers["authorization"] == "Bearer sk-standard" + + +@pytest.mark.asyncio +async def test_custom_provider_secret_injects_bearer_and_merges_extras(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"id": "x", "choices": []}) + + adapter = _adapter(handler) + await adapter.relay_chat_completion( + route=_route(), + secret=_custom_secret("sk-custom", extras={"x-org-id": "org-1"}), + context=_context(), + body=_body(), + headers={}, + ) + + request = captured["request"] + assert request.headers["authorization"] == "Bearer sk-custom" + assert request.headers["x-org-id"] == "org-1" + + +@pytest.mark.asyncio +async def test_no_secret_sends_no_authorization_header(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"id": "x", "choices": []}) + + adapter = _adapter(handler) + await adapter.relay_chat_completion( + route=_route(), secret=None, context=_context(), body=_body(), headers={} + ) + + assert "authorization" not in captured["request"].headers + + +@pytest.mark.asyncio +async def test_our_credentials_header_is_never_forwarded(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"id": "x", "choices": []}) + + adapter = _adapter(handler) + await adapter.relay_chat_completion( + route=_route(), + secret=None, + context=_context(), + body=_body(), + headers={"X-AG-Credentials": "Secret caller-token"}, + ) + + assert "x-ag-credentials" not in captured["request"].headers + + +@pytest.mark.asyncio +async def test_caller_authorization_reaches_the_upstream_when_no_secret_resolved(): + """Pass-through (OD15): the data plane reads only our own header, so `Authorization` + is the caller's own vendor auth and nothing overwrites it.""" + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"id": "x", "choices": []}) + + adapter = _adapter(handler) + await adapter.relay_chat_completion( + route=_route(), + secret=None, + context=_context(), + body=_body(), + headers={"Authorization": "Bearer caller-subscription"}, + ) + + assert captured["request"].headers["authorization"] == "Bearer caller-subscription" + + +@pytest.mark.asyncio +async def test_a_resolved_secret_overwrites_the_callers_authorization(): + """The other half of the same rule: when we do hold a secret, ours is what goes out.""" + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"id": "x", "choices": []}) + + adapter = _adapter(handler) + await adapter.relay_chat_completion( + route=_route(), + secret=_standard_secret(key="sk-ours"), + context=_context(), + body=_body(), + headers={"Authorization": "Bearer caller-subscription"}, + ) + + assert captured["request"].headers["authorization"] == "Bearer sk-ours" + + +@pytest.mark.asyncio +async def test_outbound_url_is_base_url_plus_chat_completions(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"id": "x", "choices": []}) + + adapter = _adapter(handler) + await adapter.relay_chat_completion( + route=_route(base_url="https://upstream.example/v1"), + secret=None, + context=_context(), + body=_body(), + headers={}, + ) + + assert ( + str(captured["request"].url) == "https://upstream.example/v1/chat/completions" + ) + + +@pytest.mark.asyncio +async def test_route_headers_merged_into_outbound(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"id": "x", "choices": []}) + + adapter = _adapter(handler) + await adapter.relay_chat_completion( + route=_route(), secret=None, context=_context(), body=_body(), headers={} + ) + + assert captured["request"].headers["x-route"] == "1" + + +@pytest.mark.asyncio +async def test_request_body_bytes_reach_transport_unchanged(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["content"] = request.content + return httpx.Response(200, json={"id": "x", "choices": []}) + + adapter = _adapter(handler) + body = _body() + await adapter.relay_chat_completion( + route=_route(), secret=None, context=_context(), body=body, headers={} + ) + + assert captured["content"] == body + + +@pytest.mark.asyncio +async def test_timeout_raises_llm_upstream_error(): + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ReadTimeout("timed out", request=request) + + adapter = _adapter(handler) + + with pytest.raises(LLMUpstreamError) as excinfo: + await adapter.relay_chat_completion( + route=_route(), + secret=None, + context=_context(), + body=_body(), + headers={}, + ) + + assert excinfo.value.status_code is None + + +@pytest.mark.asyncio +async def test_connection_failure_raises_llm_upstream_error_never_something_else(): + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + adapter = _adapter(handler) + + with pytest.raises(LLMUpstreamError): + await adapter.relay_chat_completion( + route=_route(), + secret=None, + context=_context(), + body=_body(), + headers={}, + ) + + +@pytest.mark.asyncio +async def test_non_timeout_5xx_raises_llm_upstream_error_with_status_code(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(503, text="upstream broke") + + adapter = _adapter(handler) + + with pytest.raises(LLMUpstreamError) as excinfo: + await adapter.relay_chat_completion( + route=_route(), + secret=None, + context=_context(), + body=_body(), + headers={}, + ) + + assert excinfo.value.status_code == 503 + + +@pytest.mark.asyncio +async def test_4xx_response_passes_through_untouched(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(400, json={"error": {"message": "bad request"}}) + + adapter = _adapter(handler) + result = await adapter.relay_chat_completion( + route=_route(), secret=None, context=_context(), body=_body(), headers={} + ) + + assert result.status_code == 400 + chunks = await _drain(result.body) + assert json.loads(chunks[0]) == {"error": {"message": "bad request"}} + + +@pytest.mark.asyncio +async def test_non_streaming_result_yields_single_chunk_and_usage(): + payload = { + "id": "x", + "choices": [], + "usage": {"prompt_tokens": 3, "completion_tokens": 5}, + } + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=payload) + + adapter = _adapter(handler) + result = await adapter.relay_chat_completion( + route=_route(), + secret=None, + context=_context(stream=False), + body=_body(), + headers={}, + ) + + chunks = await _drain(result.body) + assert len(chunks) == 1 + assert json.loads(chunks[0]) == payload + assert result.usage.input_tokens == 3 + assert result.usage.output_tokens == 5 + + +@pytest.mark.asyncio +async def test_streaming_result_passes_sse_chunks_through_unmodified(): + sse = b'data: {"choices":[]}\n\n' + b"data: [DONE]\n\n" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, content=sse, headers={"content-type": "text/event-stream"} + ) + + adapter = _adapter(handler) + result = await adapter.relay_chat_completion( + route=_route(), + secret=None, + context=_context(stream=True), + body=_body(), + headers={}, + ) + + chunks = await _drain(result.body) + assert b"".join(chunks) == sse + assert result.usage is None + + +@pytest.mark.asyncio +async def test_missing_base_url_raises_llm_upstream_error(): + adapter = RelayLLMAdapter() + + with pytest.raises(LLMUpstreamError): + await adapter.relay_chat_completion( + route=_route(base_url=None), + secret=None, + context=_context(), + body=_body(), + headers={}, + ) + + +@pytest.mark.asyncio +async def test_configured_timeout_overrides_default(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"id": "x", "choices": []}) + + adapter = _adapter(handler) + await adapter.relay_chat_completion( + route=_route(timeout_seconds=5.0), + secret=None, + context=_context(), + body=_body(), + headers={}, + ) + + assert captured["request"].extensions["timeout"]["connect"] == 5.0 + + +@pytest.mark.asyncio +async def test_bedrock_messages_request_composes_mantle_url_and_leaves_body_untouched(): + """OD19: Bedrock's Messages door moved to bedrock-mantle, which needs no rewrite — + the negative space of the Vertex pairing test below. Both assertions (URL, byte-for- + byte body including `model`) live in one test so the two halves cannot drift apart.""" + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["content"] = request.content + captured["url"] = str(request.url) + return httpx.Response(200, json={"id": "x", "content": []}) + + adapter = _adapter(handler) + route = LLMResolvedRoute( + provider_key=None, + deployment_kind=LLMDeploymentKind.BEDROCK, + model="anthropic.claude-3-5-sonnet", + base_url="https://bedrock.example", + ) + context = LLMCallContext( + model="anthropic.claude-3-5-sonnet", stream=False, protocol=LLMProtocol.MESSAGES + ) + body = json.dumps( + { + "model": "anthropic.claude-3-5-sonnet", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "hi"}], + } + ).encode() + + await adapter.relay_chat_completion( + route=route, + secret=_custom_secret(extras={"aws_bearer_token_bedrock": "bedrock-token"}), + context=context, + body=body, + headers={}, + ) + + assert captured["url"] == "https://bedrock.example/anthropic/v1/messages" + assert captured["content"] == body + + +@pytest.mark.asyncio +async def test_bedrock_messages_request_forwards_the_anthropic_version_header(): + """A native Anthropic client sends this header on every Messages call, and mantle + wants exactly it — the relay must not strip it (D34: only the caller's own headers + reach the upstream, nothing invented).""" + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["headers"] = request.headers + return httpx.Response(200, json={"id": "x", "content": []}) + + adapter = _adapter(handler) + route = LLMResolvedRoute( + provider_key=None, + deployment_kind=LLMDeploymentKind.BEDROCK, + model="anthropic.claude-3-5-sonnet", + base_url="https://bedrock.example", + ) + context = LLMCallContext( + model="anthropic.claude-3-5-sonnet", stream=False, protocol=LLMProtocol.MESSAGES + ) + + await adapter.relay_chat_completion( + route=route, + secret=_custom_secret(extras={"aws_bearer_token_bedrock": "bedrock-token"}), + context=context, + body=_body(), + headers={"anthropic-version": "2023-06-01"}, + ) + + assert captured["headers"]["anthropic-version"] == "2023-06-01" + assert captured["headers"]["authorization"] == "Bearer bedrock-token" + + +@pytest.mark.asyncio +async def test_vertex_messages_request_moves_model_from_body_to_url(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["content"] = request.content + captured["url"] = str(request.url) + return httpx.Response(200, json={"id": "x", "content": []}) + + adapter = _adapter(handler) + route = LLMResolvedRoute( + provider_key=None, + deployment_kind=LLMDeploymentKind.VERTEX, + model="claude-3-5-sonnet", + base_url="https://vertex.example", + extras={"vertex_project": "acme"}, + ) + context = LLMCallContext( + model="claude-3-5-sonnet", stream=False, protocol=LLMProtocol.MESSAGES + ) + body = json.dumps( + { + "model": "claude-3-5-sonnet", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "hi"}], + } + ).encode() + + with patch( + "litellm.llms.vertex_ai.vertex_llm_base.VertexBase.get_access_token_async", + new_callable=AsyncMock, + return_value=("minted-token", "acme"), + ): + await adapter.relay_chat_completion( + route=route, + secret=_custom_secret( + extras={"vertex_ai_credentials": '{"type": "service_account"}'} + ), + context=context, + body=body, + headers={}, + ) + + assert captured["url"] == ( + "https://vertex.example/publishers/anthropic/models/claude-3-5-sonnet:rawPredict" + ) + sent = json.loads(captured["content"]) + assert sent["anthropic_version"] == "vertex-2023-10-16" + assert "model" not in sent + assert captured["content"] != body + + +@pytest.mark.asyncio +async def test_vertex_streaming_messages_request_uses_the_stream_action(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + return httpx.Response(200, json={"id": "x", "content": []}) + + adapter = _adapter(handler) + route = LLMResolvedRoute( + provider_key=None, + deployment_kind=LLMDeploymentKind.VERTEX, + model="claude-3-5-sonnet", + base_url="https://vertex.example", + extras={"vertex_project": "acme"}, + ) + context = LLMCallContext( + model="claude-3-5-sonnet", stream=True, protocol=LLMProtocol.MESSAGES + ) + body = json.dumps( + { + "model": "claude-3-5-sonnet", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "hi"}], + } + ).encode() + + with patch( + "litellm.llms.vertex_ai.vertex_llm_base.VertexBase.get_access_token_async", + new_callable=AsyncMock, + return_value=("minted-token", "acme"), + ): + await adapter.relay_chat_completion( + route=route, + secret=_custom_secret( + extras={"vertex_ai_credentials": '{"type": "service_account"}'} + ), + context=context, + body=body, + headers={}, + ) + + assert captured["url"] == ( + "https://vertex.example/publishers/anthropic/models/claude-3-5-sonnet:streamRawPredict" + ) + + +@pytest.mark.asyncio +async def test_every_deployment_kind_other_than_vertex_stays_byte_for_byte_on_messages(): + """Names the exemption (specs-wp27.md, OD19) rather than weakening this file's other + byte-for-byte assertions: every non-Vertex kind, Bedrock included, is untouched even on + the Messages door where the rewrite is gated to apply.""" + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["content"] = request.content + return httpx.Response(200, json={"id": "x", "content": []}) + + adapter = _adapter(handler) + route = _route() # CUSTOM + context = LLMCallContext( + model="gpt-4o", stream=False, protocol=LLMProtocol.MESSAGES + ) + body = _body() + + await adapter.relay_chat_completion( + route=route, secret=None, context=context, body=body, headers={} + ) + + assert captured["content"] == body + + +@pytest.mark.asyncio +async def test_default_timeout_used_when_route_leaves_it_unset(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"id": "x", "choices": []}) + + adapter = _adapter(handler) + await adapter.relay_chat_completion( + route=_route(timeout_seconds=None), + secret=None, + context=_context(), + body=_body(), + headers={}, + ) + + assert captured["request"].extensions["timeout"]["connect"] == 60.0 diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_llm_router.py b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_router.py new file mode 100644 index 0000000000..cb0ae1d0d1 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_router.py @@ -0,0 +1,308 @@ +"""Router wiring — apis/fastapi/gateways/llms/router.py (entities.md §9). + +TestClient + a hand-written mock `LLMGatewayService` + a monkeypatched +`get_auth_scope()`/`check_action_access()` — no real database, no real service. +""" + +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from oss.src.apis.fastapi.gateways.llms.router import LLMGatewayRouter +from oss.src.core.gateways.llms.dtos import ( + LLMDeploymentKind, + LLMEndpoint, + LLMEndpointData, + LLMModelFilter, +) +from oss.src.utils.context import AuthScope + + +FIXED_SCOPE = AuthScope( + organization_id=uuid4(), + workspace_id=uuid4(), + project_id=uuid4(), + user_id=uuid4(), +) + +EXPECTED_ROUTES = { + ("/endpoints/", "POST"): "create_llm_endpoint", + ("/endpoints/", "GET"): "list_llm_endpoints", + ("/endpoints/query", "POST"): "query_llm_endpoints", + ("/endpoints/{endpoint_id}", "GET"): "fetch_llm_endpoint", + ("/endpoints/{endpoint_id}", "PUT"): "edit_llm_endpoint", + ("/endpoints/{endpoint_id}", "DELETE"): "delete_llm_endpoint", +} + + +def _endpoint(endpoint_id) -> LLMEndpoint: + return LLMEndpoint( + id=endpoint_id, + slug="acme-openai", + provider_key="openai", + deployment_kind=LLMDeploymentKind.DIRECT, + data=LLMEndpointData(models=LLMModelFilter(allowlist=["gpt-4o"])), + ) + + +class MockLLMGatewayService: + def __init__(self): + self.calls = [] + self.create_return = None + self.list_return = [] + self.query_return = [] + self.fetch_return = None + self.edit_return = None + self.delete_return = True + + async def create_endpoint(self, *, project_id, user_id, endpoint): + self.calls.append("create_endpoint") + return self.create_return + + async def list_endpoints(self, *, scope): + self.calls.append("list_endpoints") + return self.list_return + + async def query_endpoints(self, *, project_id, endpoint=None, windowing=None): + self.calls.append("query_endpoints") + return self.query_return + + async def fetch_endpoint(self, *, project_id, endpoint_id): + self.calls.append("fetch_endpoint") + return self.fetch_return + + async def edit_endpoint(self, *, project_id, user_id, endpoint): + self.calls.append("edit_endpoint") + return self.edit_return + + async def delete_endpoint(self, *, project_id, endpoint_id): + self.calls.append("delete_endpoint") + return self.delete_return + + +@pytest.fixture +def service(): + return MockLLMGatewayService() + + +@pytest.fixture +def router(service): + return LLMGatewayRouter(llm_gateway_service=service) + + +@pytest.fixture +def client(router): + app = FastAPI() + app.include_router(router.router) + return TestClient(app) + + +@pytest.fixture(autouse=True) +def _patch_auth_scope(monkeypatch): + monkeypatch.setattr( + "oss.src.apis.fastapi.gateways.llms.router.get_auth_scope", + lambda: FIXED_SCOPE, + ) + + +@pytest.fixture +def allow(monkeypatch): + mock = AsyncMock(return_value=True) + monkeypatch.setattr( + "oss.src.apis.fastapi.gateways.llms.router.check_action_access", mock + ) + return mock + + +@pytest.fixture +def deny(monkeypatch): + mock = AsyncMock(return_value=False) + monkeypatch.setattr( + "oss.src.apis.fastapi.gateways.llms.router.check_action_access", mock + ) + return mock + + +# --------------------------------------------------------------------------- +# Route table — path, method and operation_id match entities.md §9 exactly +# --------------------------------------------------------------------------- + + +def test_route_table_matches_the_design_exactly(router): + actual = {} + for route in router.router.routes: + for method in route.methods: + if method == "HEAD": + continue + actual[(route.path, method)] = route.operation_id + + assert actual == EXPECTED_ROUTES + + +# --------------------------------------------------------------------------- +# Each route reaches the right handler (happy path) +# --------------------------------------------------------------------------- + + +def test_create_endpoint_reaches_the_service(client, service, allow): + endpoint_id = uuid4() + service.create_return = _endpoint(endpoint_id) + + response = client.post( + "/endpoints/", + json={ + "endpoint": { + "slug": "acme-openai", + "provider_key": "openai", + "deployment_kind": "direct", + "data": {"models": {"allowlist": ["gpt-4o"]}}, + } + }, + ) + + assert response.status_code == 200 + assert response.json()["count"] == 1 + assert response.json()["endpoint"]["id"] == str(endpoint_id) + assert service.calls == ["create_endpoint"] + + +def test_list_endpoints_reaches_the_service(client, service, allow): + service.list_return = [_endpoint(uuid4())] + + response = client.get("/endpoints/") + + assert response.status_code == 200 + assert response.json()["count"] == 1 + assert service.calls == ["list_endpoints"] + + +def test_query_endpoints_reaches_the_service(client, service, allow): + service.query_return = [_endpoint(uuid4())] + + response = client.post("/endpoints/query", json={}) + + assert response.status_code == 200 + assert response.json()["count"] == 1 + assert service.calls == ["query_endpoints"] + + +def test_fetch_endpoint_reaches_the_service(client, service, allow): + endpoint_id = uuid4() + service.fetch_return = _endpoint(endpoint_id) + + response = client.get(f"/endpoints/{endpoint_id}") + + assert response.status_code == 200 + assert response.json()["endpoint"]["id"] == str(endpoint_id) + assert service.calls == ["fetch_endpoint"] + + +def test_edit_endpoint_reaches_the_service(client, service, allow): + endpoint_id = uuid4() + service.edit_return = _endpoint(endpoint_id) + + response = client.put( + f"/endpoints/{endpoint_id}", + json={ + "endpoint": { + "id": str(endpoint_id), + "data": {"models": {"allowlist": ["gpt-4o-mini"]}}, + } + }, + ) + + assert response.status_code == 200 + assert service.calls == ["edit_endpoint"] + + +def test_edit_endpoint_rejects_a_path_body_id_mismatch(client, service, allow): + endpoint_id = uuid4() + other_id = uuid4() + + response = client.put( + f"/endpoints/{endpoint_id}", + json={"endpoint": {"id": str(other_id), "data": {}}}, + ) + + assert response.status_code == 400 + assert service.calls == [] + + +def test_delete_endpoint_reaches_the_service(client, service, allow): + endpoint_id = uuid4() + service.delete_return = True + + response = client.delete(f"/endpoints/{endpoint_id}") + + assert response.status_code == 204 + assert service.calls == ["delete_endpoint"] + + +# --------------------------------------------------------------------------- +# A denied _check short-circuits before the mock service is called +# --------------------------------------------------------------------------- + + +# Fixed (not `uuid4()`-at-collection-time) so pytest-xdist workers agree on +# the parametrize IDs — a random id per worker process fails collection. +_A_FIXED_ID = "00000000-0000-0000-0000-000000000001" + + +@pytest.mark.parametrize( + "method,path,json_body", + [ + ( + "POST", + "/endpoints/", + {"endpoint": {"provider_key": "openai", "deployment_kind": "direct"}}, + ), + ("GET", "/endpoints/", None), + ("POST", "/endpoints/query", {}), + ("GET", f"/endpoints/{_A_FIXED_ID}", None), + ("PUT", f"/endpoints/{_A_FIXED_ID}", {"endpoint": {"data": {}}}), + ("DELETE", f"/endpoints/{_A_FIXED_ID}", None), + ], +) +def test_denied_check_short_circuits_before_the_service_is_called( + client, service, deny, method, path, json_body +): + response = client.request(method, path, json=json_body) + + assert response.status_code == 403 + assert service.calls == [] + + +# --------------------------------------------------------------------------- +# None/False from the service maps to 404 +# --------------------------------------------------------------------------- + + +def test_fetch_endpoint_none_maps_to_404(client, service, allow): + service.fetch_return = None + + response = client.get(f"/endpoints/{uuid4()}") + + assert response.status_code == 404 + + +def test_edit_endpoint_none_maps_to_404(client, service, allow): + endpoint_id = uuid4() + service.edit_return = None + + response = client.put( + f"/endpoints/{endpoint_id}", + json={"endpoint": {"id": str(endpoint_id), "data": {}}}, + ) + + assert response.status_code == 404 + + +def test_delete_endpoint_false_maps_to_404(client, service, allow): + service.delete_return = False + + response = client.delete(f"/endpoints/{uuid4()}") + + assert response.status_code == 404 diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_llm_routing_strategies.py b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_routing_strategies.py new file mode 100644 index 0000000000..e0cc9799f4 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_routing_strategies.py @@ -0,0 +1,268 @@ +"""Unit tests for routing.py's per-deployment URL composition (specs-wp24.md Phase 1).""" + +import pytest + +from oss.src.core.gateways.llms.dtos import ( + LLMDeploymentKind, + LLMProtocol, + LLMResolvedRoute, +) +from oss.src.core.gateways.llms.providers.passthrough.routing import build_url +from oss.src.core.gateways.llms.types import LLMUpstreamError + + +def _route(**overrides) -> LLMResolvedRoute: + base = dict( + provider_key="openai", deployment_kind=LLMDeploymentKind.DIRECT, model="gpt-4o" + ) + base.update(overrides) + return LLMResolvedRoute(**base) + + +def test_direct_known_provider_uses_the_catalogued_base_url(): + route = _route(provider_key="groq") + assert ( + build_url(route, LLMProtocol.CHAT_COMPLETIONS) + == "https://api.groq.com/openai/v1/chat/completions" + ) + + +def test_direct_anthropic_uses_the_messages_door(): + route = _route(provider_key="anthropic") + assert ( + build_url(route, LLMProtocol.MESSAGES) + == "https://api.anthropic.com/v1/messages" + ) + + +def test_direct_row_base_url_overrides_the_catalogue(): + route = _route(provider_key="openai", base_url="https://proxy.example/v1") + assert ( + build_url(route, LLMProtocol.CHAT_COMPLETIONS) + == "https://proxy.example/v1/chat/completions" + ) + + +def test_direct_unknown_provider_raises_naming_it(): + route = _route(provider_key="totally-unheard-of") + with pytest.raises(LLMUpstreamError) as excinfo: + build_url(route, LLMProtocol.CHAT_COMPLETIONS) + assert "totally-unheard-of" in str(excinfo.value) + + +def test_custom_deployment_uses_the_row_base_url(): + route = _route( + deployment_kind=LLMDeploymentKind.CUSTOM, base_url="https://acme.internal/v1" + ) + assert ( + build_url(route, LLMProtocol.CHAT_COMPLETIONS) + == "https://acme.internal/v1/chat/completions" + ) + + +def test_custom_deployment_with_no_base_url_raises(): + route = _route(deployment_kind=LLMDeploymentKind.CUSTOM, base_url=None) + with pytest.raises(LLMUpstreamError): + build_url(route, LLMProtocol.CHAT_COMPLETIONS) + + +def test_azure_composes_deployment_path_and_api_version(): + route = _route( + deployment_kind=LLMDeploymentKind.AZURE, + base_url="https://acme.openai.azure.com", + api_version="2024-10-21", + model="gpt-4o", + ) + assert build_url(route, LLMProtocol.CHAT_COMPLETIONS) == ( + "https://acme.openai.azure.com/openai/deployments/gpt-4o/chat/completions" + "?api-version=2024-10-21" + ) + + +def test_azure_with_no_base_url_raises(): + route = _route(deployment_kind=LLMDeploymentKind.AZURE, base_url=None) + with pytest.raises(LLMUpstreamError): + build_url(route, LLMProtocol.CHAT_COMPLETIONS) + + +def test_bedrock_composes_mantle_host_from_region(): + route = _route(deployment_kind=LLMDeploymentKind.BEDROCK, region="eu-central-1") + assert build_url(route, LLMProtocol.CHAT_COMPLETIONS) == ( + "https://bedrock-mantle.eu-central-1.api.aws/v1/chat/completions" + ) + + +def test_bedrock_with_no_region_or_base_url_raises(): + route = _route(deployment_kind=LLMDeploymentKind.BEDROCK, region=None) + with pytest.raises(LLMUpstreamError): + build_url(route, LLMProtocol.CHAT_COMPLETIONS) + + +def test_vertex_composes_openapi_host_from_region_and_project(): + route = _route( + deployment_kind=LLMDeploymentKind.VERTEX, + region="europe-west4", + extras={"vertex_project": "acme-prod"}, + ) + assert build_url(route, LLMProtocol.CHAT_COMPLETIONS) == ( + "https://europe-west4-aiplatform.googleapis.com/v1/projects/acme-prod" + "/locations/europe-west4/endpoints/openapi/chat/completions" + ) + + +def test_vertex_with_no_project_raises(): + route = _route( + deployment_kind=LLMDeploymentKind.VERTEX, region="europe-west4", extras=None + ) + with pytest.raises(LLMUpstreamError): + build_url(route, LLMProtocol.CHAT_COMPLETIONS) + + +def test_bedrock_messages_door_composes_mantle_anthropic_path(): + """OD19: Bedrock's Messages door moved to bedrock-mantle. No model in the URL — it + stays in the body untouched (static_fields.py has no BEDROCK entry any more).""" + route = _route( + deployment_kind=LLMDeploymentKind.BEDROCK, + base_url="https://bedrock.example", + model="anthropic.claude-3-5-sonnet", + ) + assert build_url(route, LLMProtocol.MESSAGES) == ( + "https://bedrock.example/anthropic/v1/messages" + ) + + +def test_bedrock_messages_door_derives_mantle_host_from_region(): + route = _route( + deployment_kind=LLMDeploymentKind.BEDROCK, + region="eu-central-1", + model="anthropic.claude-3-5-sonnet", + ) + assert build_url(route, LLMProtocol.MESSAGES) == ( + "https://bedrock-mantle.eu-central-1.api.aws/anthropic/v1/messages" + ) + + +def test_bedrock_messages_door_stream_flag_does_not_change_the_url(): + """Mantle's Anthropic surface streams via the body's own `stream` flag, not a + separate operation the way legacy InvokeModel named it.""" + route = _route( + deployment_kind=LLMDeploymentKind.BEDROCK, + base_url="https://bedrock.example", + model="anthropic.claude-3-5-sonnet", + ) + assert build_url(route, LLMProtocol.MESSAGES, stream=True) == build_url( + route, LLMProtocol.MESSAGES, stream=False + ) + + +def test_bedrock_messages_door_with_no_region_or_base_url_raises(): + route = _route(deployment_kind=LLMDeploymentKind.BEDROCK, region=None) + with pytest.raises(LLMUpstreamError): + build_url(route, LLMProtocol.MESSAGES) + + +def test_bedrock_base_url_is_a_host_override_shared_by_every_door(): + """OD19: base_url on a BEDROCK row is a pure host override; each door appends its own + tail on top of it, and one stored value composes correctly on all three.""" + route = _route( + deployment_kind=LLMDeploymentKind.BEDROCK, base_url="https://vpce.example" + ) + assert build_url(route, LLMProtocol.CHAT_COMPLETIONS) == ( + "https://vpce.example/v1/chat/completions" + ) + assert ( + build_url(route, LLMProtocol.RESPONSES) == "https://vpce.example/v1/responses" + ) + assert build_url(route, LLMProtocol.MESSAGES) == ( + "https://vpce.example/anthropic/v1/messages" + ) + + +def test_bedrock_chat_completions_door_is_unaffected_by_the_messages_door(): + route = _route(deployment_kind=LLMDeploymentKind.BEDROCK, region="eu-central-1") + assert build_url(route, LLMProtocol.CHAT_COMPLETIONS) == ( + "https://bedrock-mantle.eu-central-1.api.aws/v1/chat/completions" + ) + + +def test_vertex_messages_door_composes_raw_predict_path(): + route = _route( + deployment_kind=LLMDeploymentKind.VERTEX, + base_url="https://vertex.example", + model="claude-3-5-sonnet", + ) + assert build_url(route, LLMProtocol.MESSAGES) == ( + "https://vertex.example/publishers/anthropic/models/claude-3-5-sonnet:rawPredict" + ) + + +def test_vertex_messages_door_streaming_uses_stream_raw_predict(): + route = _route( + deployment_kind=LLMDeploymentKind.VERTEX, + base_url="https://vertex.example", + model="claude-3-5-sonnet", + ) + assert build_url(route, LLMProtocol.MESSAGES, stream=True) == ( + "https://vertex.example/publishers/anthropic/models" + "/claude-3-5-sonnet:streamRawPredict" + ) + + +def test_vertex_messages_door_derives_project_host_from_region_and_project(): + route = _route( + deployment_kind=LLMDeploymentKind.VERTEX, + region="europe-west4", + extras={"vertex_project": "acme-prod"}, + model="claude-3-5-sonnet", + ) + assert build_url(route, LLMProtocol.MESSAGES) == ( + "https://europe-west4-aiplatform.googleapis.com/v1/projects/acme-prod" + "/locations/europe-west4/publishers/anthropic/models/claude-3-5-sonnet:rawPredict" + ) + + +def test_vertex_messages_door_with_no_model_raises_naming_vertex(): + route = _route( + deployment_kind=LLMDeploymentKind.VERTEX, base_url="https://v.example" + ) + route.model = "" + with pytest.raises(LLMUpstreamError): + build_url(route, LLMProtocol.MESSAGES) + + +def test_vertex_chat_completions_door_is_unaffected_by_the_messages_strategy(): + route = _route( + deployment_kind=LLMDeploymentKind.VERTEX, + region="europe-west4", + extras={"vertex_project": "acme-prod"}, + ) + assert build_url(route, LLMProtocol.CHAT_COMPLETIONS) == ( + "https://europe-west4-aiplatform.googleapis.com/v1/projects/acme-prod" + "/locations/europe-west4/endpoints/openapi/chat/completions" + ) + + +def test_vertex_base_url_is_host_plus_shared_prefix_serving_both_doors(): + """OD19: base_url on a VERTEX row is the host plus the shared + /v1/projects/{project}/locations/{region} prefix; each door appends only its own tail, + so one stored value serves both.""" + route = _route( + deployment_kind=LLMDeploymentKind.VERTEX, + base_url="https://priv.example/v1/projects/acme-prod/locations/europe-west4", + model="claude-3-5-sonnet", + ) + assert build_url(route, LLMProtocol.CHAT_COMPLETIONS) == ( + "https://priv.example/v1/projects/acme-prod/locations/europe-west4" + "/endpoints/openapi/chat/completions" + ) + assert build_url(route, LLMProtocol.MESSAGES) == ( + "https://priv.example/v1/projects/acme-prod/locations/europe-west4" + "/publishers/anthropic/models/claude-3-5-sonnet:rawPredict" + ) + + +def test_sagemaker_always_raises_naming_the_reason(): + route = _route(deployment_kind=LLMDeploymentKind.SAGEMAKER, region="us-east-1") + with pytest.raises(LLMUpstreamError) as excinfo: + build_url(route, LLMProtocol.CHAT_COMPLETIONS) + assert "sagemaker" in (excinfo.value.detail or "").lower() diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_llm_service.py b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_service.py new file mode 100644 index 0000000000..44fa7e8a76 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_service.py @@ -0,0 +1,665 @@ +"""Unit tests for `LLMGatewayService` (specs-wp7.md, tasks-wp7.md Phases 5, 5b, 6). + +Stubbed DAO/policy/resolver/registry — no real adapters, no compose, nothing running. +""" + +import json +from typing import AsyncIterator, Dict, List, Optional, Set +from uuid import uuid4 + +import pytest + +from oss.src.core.gateways.dtos import GatewayEndpointNamespace +from oss.src.core.gateways.llms.dtos import ( + LLMDeploymentKind, + LLMEndpoint, + LLMEndpointData, + LLMEndpointSettings, + LLMModelFilter, + LLMProtocol, +) +from oss.src.core.gateways.llms.interfaces import ( + LLMEndpointsDAOInterface, + LLMRelayResult, + LLMUpstreamInterface, +) +from oss.src.core.gateways.llms.registry import LLMUpstreamRegistry +from oss.src.core.gateways.llms.service import LLMGatewayService +from oss.src.core.gateways.llms.types import ( + LLMEndpointNotFoundError, + LLMModelNotAllowedError, +) +from oss.src.core.gateways.policy.dtos import ( + SecretMode, + SecretOwner, + SecretOwnerKind, + GatewayUsage, + PolicyDecision, + ResolvedSecret, + SecretOrigin, +) +from oss.src.core.gateways.policy.interfaces import SecretsResolverInterface +from oss.src.core.gateways.policy.types import CeilingExceededError, PolicyDeniedError +from oss.src.core.secrets.dtos import ( + SecretResponseDTO, + StandardProviderDTO, + StandardProviderSettingsDTO, +) +from oss.src.core.secrets.enums import SecretKind, StandardProviderKind +from oss.src.core.shared.dtos import Header +from oss.src.utils.context import AuthScope + + +def _scope() -> AuthScope: + return AuthScope( + organization_id=uuid4(), + workspace_id=uuid4(), + project_id=uuid4(), + user_id=uuid4(), + ) + + +def _custom_row( + *, + slug="acme", + provider_key="openai", + deployment_kind=LLMDeploymentKind.CUSTOM, + models=None, + max_output_tokens=None, + secret_id=None, +) -> LLMEndpoint: + return LLMEndpoint( + id=uuid4(), + slug=slug, + provider_key=provider_key, + deployment_kind=deployment_kind, + namespace=GatewayEndpointNamespace.CUSTOM, + secret_id=secret_id, + data=LLMEndpointData( + models=models or LLMModelFilter(allowlist=["gpt-4o"]), + settings=LLMEndpointSettings(max_output_tokens=max_output_tokens), + ), + ) + + +def _secret() -> ResolvedSecret: + return ResolvedSecret( + secret=SecretResponseDTO( + kind=SecretKind.PROVIDER_KEY, + data=StandardProviderDTO( + kind=StandardProviderKind.OPENAI, + provider=StandardProviderSettingsDTO(key="sk-test"), + ), + header=Header(name="openai"), + ), + owner=SecretOwner(kind=SecretOwnerKind.PROJECT), + origin=SecretOrigin.VAULT, + ) + + +class _MockLlmEndpointsDAO(LLMEndpointsDAOInterface): + def __init__(self): + self.calls: List[tuple] = [] + self.rows_by_slug: Dict[str, LLMEndpoint] = {} + self.query_result: List[LLMEndpoint] = [] + self.create_result: Optional[LLMEndpoint] = None + self.fetch_result: Optional[LLMEndpoint] = None + self.edit_result: Optional[LLMEndpoint] = None + self.delete_result: bool = True + + async def create_endpoint(self, *, project_id, user_id, endpoint): + self.calls.append(("create_endpoint", project_id, user_id, endpoint)) + return self.create_result + + async def fetch_endpoint(self, *, project_id, endpoint_id): + self.calls.append(("fetch_endpoint", project_id, endpoint_id)) + return self.fetch_result + + async def fetch_endpoint_by_slug(self, *, project_id, slug): + self.calls.append(("fetch_endpoint_by_slug", project_id, slug)) + return self.rows_by_slug.get(slug) + + async def edit_endpoint(self, *, project_id, user_id, endpoint): + self.calls.append(("edit_endpoint", project_id, user_id, endpoint)) + return self.edit_result + + async def delete_endpoint(self, *, project_id, endpoint_id): + self.calls.append(("delete_endpoint", project_id, endpoint_id)) + return self.delete_result + + async def query_endpoints(self, *, project_id, endpoint=None, windowing=None): + self.calls.append(("query_endpoints", project_id, endpoint, windowing)) + return self.query_result + + +class _MockResolver(SecretsResolverInterface): + def __init__( + self, + *, + provider_keys: Optional[Set[str]] = None, + secret: Optional[ResolvedSecret] = None, + ): + self.provider_keys = provider_keys or set() + self.secret = secret + self.resolve_calls: List[tuple] = [] + + async def resolve(self, *, scope, ref, mode): + self.resolve_calls.append((scope, ref, mode)) + assert self.secret is not None, "resolve() called with no secret stubbed" + return self.secret + + async def available_provider_keys(self, *, scope) -> Set[str]: + return self.provider_keys + + +class _MockPolicy: + def __init__(self, *, allowed: bool = True): + self.allowed = allowed + self.authorize_calls: List[tuple] = [] + self.record_calls: List[tuple] = [] + + async def authorize(self, *, scope, permission, target): + self.authorize_calls.append((scope, permission, target)) + return PolicyDecision( + allowed=self.allowed, + permission=permission, + reason=None if self.allowed else "permission_denied", + ) + + async def record(self, *, scope, target, decision, outcome): + self.record_calls.append((scope, target, decision, outcome)) + + +class _MockAdapter(LLMUpstreamInterface): + def __init__(self, *, result: LLMRelayResult): + self.result = result + self.calls: List[dict] = [] + + async def relay_chat_completion(self, *, route, secret, context, body, headers): + self.calls.append( + { + "route": route, + "secret": secret, + "context": context, + "body": body, + "headers": headers, + } + ) + return self.result + + +async def _one_chunk_body(data: bytes) -> AsyncIterator[bytes]: + yield data + + +def _service( + *, dao=None, policy=None, resolver=None, registry=None +) -> LLMGatewayService: + return LLMGatewayService( + llm_endpoints_dao=dao if dao is not None else _MockLlmEndpointsDAO(), + policy=policy if policy is not None else _MockPolicy(), + resolver=resolver if resolver is not None else _MockResolver(), + upstream_registry=registry + if registry is not None + else LLMUpstreamRegistry(adapters={}), + ) + + +# --- management: thin delegation ------------------------------------------- # + + +@pytest.mark.asyncio +async def test_create_endpoint_delegates_to_dao_unchanged(): + dao = _MockLlmEndpointsDAO() + dao.create_result = _custom_row() + project_id, user_id = uuid4(), uuid4() + endpoint_create = object() + + result = await _service(dao=dao).create_endpoint( + project_id=project_id, user_id=user_id, endpoint=endpoint_create + ) + + assert result is dao.create_result + assert dao.calls == [("create_endpoint", project_id, user_id, endpoint_create)] + + +@pytest.mark.asyncio +async def test_fetch_endpoint_delegates_to_dao_unchanged(): + dao = _MockLlmEndpointsDAO() + dao.fetch_result = _custom_row() + project_id, endpoint_id = uuid4(), uuid4() + + result = await _service(dao=dao).fetch_endpoint( + project_id=project_id, endpoint_id=endpoint_id + ) + + assert result is dao.fetch_result + assert dao.calls == [("fetch_endpoint", project_id, endpoint_id)] + + +@pytest.mark.asyncio +async def test_edit_endpoint_delegates_to_dao_unchanged(): + dao = _MockLlmEndpointsDAO() + dao.edit_result = _custom_row() + project_id, user_id = uuid4(), uuid4() + endpoint_edit = object() + + result = await _service(dao=dao).edit_endpoint( + project_id=project_id, user_id=user_id, endpoint=endpoint_edit + ) + + assert result is dao.edit_result + assert dao.calls == [("edit_endpoint", project_id, user_id, endpoint_edit)] + + +@pytest.mark.asyncio +async def test_delete_endpoint_delegates_to_dao_unchanged(): + dao = _MockLlmEndpointsDAO() + project_id, endpoint_id = uuid4(), uuid4() + + result = await _service(dao=dao).delete_endpoint( + project_id=project_id, endpoint_id=endpoint_id + ) + + assert result is True + assert dao.calls == [("delete_endpoint", project_id, endpoint_id)] + + +@pytest.mark.asyncio +async def test_query_endpoints_delegates_to_dao_unchanged(): + dao = _MockLlmEndpointsDAO() + dao.query_result = [_custom_row()] + project_id = uuid4() + + result = await _service(dao=dao).query_endpoints(project_id=project_id) + + assert result is dao.query_result + assert dao.calls == [("query_endpoints", project_id, None, None)] + + +# --- list_endpoints: the merge (D20) ---------------------------------------- # + + +@pytest.mark.asyncio +async def test_list_endpoints_merges_generated_and_custom_with_two_keys(): + dao = _MockLlmEndpointsDAO() + custom_row = _custom_row(slug="acme") + dao.query_result = [custom_row] + resolver = _MockResolver(provider_keys={"openai", "anthropic"}) + + result = await _service(dao=dao, resolver=resolver).list_endpoints(scope=_scope()) + + generated = [e for e in result if e.namespace == GatewayEndpointNamespace.STANDARD] + assert {e.provider_key for e in generated} == {"openai", "anthropic"} + assert custom_row in result + assert len(result) == len(generated) + 1 + + +@pytest.mark.asyncio +async def test_list_endpoints_with_no_keys_yields_custom_rows_only(): + dao = _MockLlmEndpointsDAO() + custom_row = _custom_row(slug="acme") + dao.query_result = [custom_row] + resolver = _MockResolver(provider_keys=set()) + + result = await _service(dao=dao, resolver=resolver).list_endpoints(scope=_scope()) + + assert result == [custom_row] + + +# --- list_models (R3) ------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_list_models_custom_returns_the_allowlist_exactly(): + dao = _MockLlmEndpointsDAO() + dao.rows_by_slug["acme"] = _custom_row( + slug="acme", models=LLMModelFilter(allowlist=["a", "b"]) + ) + + slugs = await _service(dao=dao).list_models( + scope=_scope(), namespace=GatewayEndpointNamespace.CUSTOM, name="acme" + ) + + assert slugs == ["a", "b"] + + +@pytest.mark.asyncio +async def test_list_models_standard_strips_the_litellm_routing_prefix(): + """catalog.py bares each id (open-designs.md OD16): the litellm-flavored catalogue + entry means nothing to Anthropic's real API, and D34 forbids fixing that at relay + time, so the fix is in what the catalogue advertises.""" + slugs = await _service().list_models( + scope=_scope(), namespace=GatewayEndpointNamespace.STANDARD, name="anthropic" + ) + + assert slugs[0] == "claude-fable-5" + assert not any(slug.startswith("anthropic/") for slug in slugs) + + +@pytest.mark.asyncio +async def test_list_models_unknown_name_raises_not_found(): + with pytest.raises(LLMEndpointNotFoundError): + await _service().list_models( + scope=_scope(), namespace=GatewayEndpointNamespace.CUSTOM, name="ghost" + ) + + +@pytest.mark.asyncio +async def test_list_models_denied_decision_raises_before_reading_slugs(): + dao = _MockLlmEndpointsDAO() + dao.rows_by_slug["acme"] = _custom_row( + slug="acme", models=LLMModelFilter(allowlist=["a"]) + ) + policy = _MockPolicy(allowed=False) + + with pytest.raises(PolicyDeniedError): + await _service(dao=dao, policy=policy).list_models( + scope=_scope(), namespace=GatewayEndpointNamespace.CUSTOM, name="acme" + ) + assert len(policy.authorize_calls) == 1 + + +# --- relay_chat_completion: the three orderings ----------------------------- # + + +@pytest.mark.asyncio +async def test_disallowed_model_raises_without_calling_resolver(): + dao = _MockLlmEndpointsDAO() + dao.rows_by_slug["acme"] = _custom_row( + slug="acme", models=LLMModelFilter(allowlist=["gpt-4o"]) + ) + resolver = _MockResolver(secret=_secret()) + + body = json.dumps({"model": "gpt-4o-mini", "messages": []}).encode() + + with pytest.raises(LLMModelNotAllowedError): + await _service(dao=dao, resolver=resolver).relay_chat_completion( + scope=_scope(), + namespace=GatewayEndpointNamespace.CUSTOM, + name="acme", + body=body, + headers={}, + ) + + assert resolver.resolve_calls == [] + + +@pytest.mark.parametrize( + "protocol", + [LLMProtocol.CHAT_COMPLETIONS, LLMProtocol.RESPONSES, LLMProtocol.MESSAGES], +) +@pytest.mark.asyncio +async def test_disallowed_model_is_refused_on_every_door_before_the_secret(protocol): + dao = _MockLlmEndpointsDAO() + dao.rows_by_slug["acme"] = _custom_row( + slug="acme", models=LLMModelFilter(allowlist=["gpt-4o"]) + ) + resolver = _MockResolver(secret=_secret()) + + body = json.dumps({"model": "gpt-4o-mini"}).encode() + + with pytest.raises(LLMModelNotAllowedError): + await _service(dao=dao, resolver=resolver).relay_chat_completion( + scope=_scope(), + namespace=GatewayEndpointNamespace.CUSTOM, + name="acme", + body=body, + headers={}, + protocol=protocol, + ) + + assert resolver.resolve_calls == [] + + +@pytest.mark.asyncio +async def test_policy_denial_records_once_before_raising(): + dao = _MockLlmEndpointsDAO() + dao.rows_by_slug["acme"] = _custom_row( + slug="acme", models=LLMModelFilter(allowlist=["gpt-4o"]) + ) + policy = _MockPolicy(allowed=False) + resolver = _MockResolver(secret=_secret()) + + body = json.dumps({"model": "gpt-4o", "messages": []}).encode() + + with pytest.raises(PolicyDeniedError): + await _service(dao=dao, policy=policy, resolver=resolver).relay_chat_completion( + scope=_scope(), + namespace=GatewayEndpointNamespace.CUSTOM, + name="acme", + body=body, + headers={}, + ) + + assert len(policy.record_calls) == 1 + outcome = policy.record_calls[0][3] + assert outcome.status_code == 403 + assert resolver.resolve_calls == [] + + +@pytest.mark.asyncio +async def test_ceiling_breach_names_all_three_values(): + dao = _MockLlmEndpointsDAO() + dao.rows_by_slug["acme"] = _custom_row( + slug="acme", models=LLMModelFilter(allowlist=["gpt-4o"]), max_output_tokens=100 + ) + resolver = _MockResolver(secret=_secret()) + + # Chat Completions' request field is `max_tokens`, not the `max_output_tokens` + # config key (D33: which request field varies per protocol; the ceiling itself + # is always named `max_output_tokens` in the error). + body = json.dumps({"model": "gpt-4o", "messages": [], "max_tokens": 200}).encode() + + with pytest.raises(CeilingExceededError) as excinfo: + await _service(dao=dao, resolver=resolver).relay_chat_completion( + scope=_scope(), + namespace=GatewayEndpointNamespace.CUSTOM, + name="acme", + body=body, + headers={}, + ) + + assert excinfo.value.ceiling == "max_output_tokens" + assert excinfo.value.requested == 200 + assert excinfo.value.allowed == 100 + assert resolver.resolve_calls == [] + + +# --- ceiling binding is per protocol (D33, D34, WP23) ------------------------ # + + +@pytest.mark.parametrize( + "protocol,field", + [ + (LLMProtocol.CHAT_COMPLETIONS, "max_tokens"), + (LLMProtocol.CHAT_COMPLETIONS, "max_completion_tokens"), + (LLMProtocol.RESPONSES, "max_output_tokens"), + (LLMProtocol.MESSAGES, "max_tokens"), + ], +) +@pytest.mark.asyncio +async def test_ceiling_binds_to_the_protocols_own_field_and_rejects_above_it( + protocol, field +): + dao = _MockLlmEndpointsDAO() + dao.rows_by_slug["acme"] = _custom_row( + slug="acme", models=LLMModelFilter(allowlist=["gpt-4o"]), max_output_tokens=100 + ) + resolver = _MockResolver(secret=_secret()) + + body = json.dumps({"model": "gpt-4o", field: 200}).encode() + + with pytest.raises(CeilingExceededError) as excinfo: + await _service(dao=dao, resolver=resolver).relay_chat_completion( + scope=_scope(), + namespace=GatewayEndpointNamespace.CUSTOM, + name="acme", + body=body, + headers={}, + protocol=protocol, + ) + + assert excinfo.value.requested == 200 + assert excinfo.value.allowed == 100 + assert resolver.resolve_calls == [] + + +@pytest.mark.parametrize( + "protocol,field", + [ + (LLMProtocol.CHAT_COMPLETIONS, "max_tokens"), + (LLMProtocol.RESPONSES, "max_output_tokens"), + (LLMProtocol.MESSAGES, "max_tokens"), + ], +) +@pytest.mark.asyncio +async def test_ceiling_at_or_below_the_limit_is_not_rejected(protocol, field): + dao = _MockLlmEndpointsDAO() + dao.rows_by_slug["acme"] = _custom_row( + slug="acme", models=LLMModelFilter(allowlist=["gpt-4o"]), max_output_tokens=100 + ) + secret = _secret() + resolver = _MockResolver(secret=secret) + + adapter_result = LLMRelayResult( + status_code=200, headers={}, body=_one_chunk_body(b"{}") + ) + adapter = _MockAdapter(result=adapter_result) + registry = LLMUpstreamRegistry(adapters={"relay": adapter}) + policy = _MockPolicy(allowed=True) + + body = json.dumps({"model": "gpt-4o", field: 100}).encode() + + result = await _service( + dao=dao, resolver=resolver, registry=registry, policy=policy + ).relay_chat_completion( + scope=_scope(), + namespace=GatewayEndpointNamespace.CUSTOM, + name="acme", + body=body, + headers={}, + protocol=protocol, + ) + + assert result is adapter_result + + +@pytest.mark.asyncio +async def test_ceiling_ignores_another_protocols_field_name(): + # A Responses body naming `max_tokens` (Chat Completions'/Messages' field) is not + # mistaken for the ceiling field — RESPONSES only reads `max_output_tokens`. + dao = _MockLlmEndpointsDAO() + dao.rows_by_slug["acme"] = _custom_row( + slug="acme", models=LLMModelFilter(allowlist=["gpt-4o"]), max_output_tokens=100 + ) + secret = _secret() + resolver = _MockResolver(secret=secret) + + adapter_result = LLMRelayResult( + status_code=200, headers={}, body=_one_chunk_body(b"{}") + ) + adapter = _MockAdapter(result=adapter_result) + registry = LLMUpstreamRegistry(adapters={"relay": adapter}) + policy = _MockPolicy(allowed=True) + + body = json.dumps({"model": "gpt-4o", "max_tokens": 999}).encode() + + result = await _service( + dao=dao, resolver=resolver, registry=registry, policy=policy + ).relay_chat_completion( + scope=_scope(), + namespace=GatewayEndpointNamespace.CUSTOM, + name="acme", + body=body, + headers={}, + protocol=LLMProtocol.RESPONSES, + ) + + assert result is adapter_result + + +@pytest.mark.asyncio +async def test_successful_non_streaming_call_records_after_relay(): + dao = _MockLlmEndpointsDAO() + row = _custom_row( + slug="acme", models=LLMModelFilter(allowlist=["gpt-4o"]), secret_id=uuid4() + ) + dao.rows_by_slug["acme"] = row + secret = _secret() + resolver = _MockResolver(secret=secret) + + adapter_result = LLMRelayResult( + status_code=200, + headers={}, + body=_one_chunk_body(b'{"ok": true}'), + usage=GatewayUsage(calls=1, input_tokens=3, output_tokens=4, cost=0.01), + ) + adapter = _MockAdapter(result=adapter_result) + registry = LLMUpstreamRegistry(adapters={"relay": adapter}) + policy = _MockPolicy(allowed=True) + + body = json.dumps({"model": "gpt-4o", "messages": []}).encode() + result = await _service( + dao=dao, resolver=resolver, registry=registry, policy=policy + ).relay_chat_completion( + scope=_scope(), + namespace=GatewayEndpointNamespace.CUSTOM, + name="acme", + body=body, + headers={}, + ) + + assert result is adapter_result + assert len(adapter.calls) == 1 + assert resolver.resolve_calls[0][2] == SecretMode.PROJECT_ONLY + + # Adapters fill usage while the body generator runs, so recording waits for the + # drain here exactly as it does for a stream. + assert policy.record_calls == [] + assert [chunk async for chunk in result.body] == [b'{"ok": true}'] + + assert len(policy.record_calls) == 1 + outcome = policy.record_calls[0][3] + assert outcome.status_code == 200 + assert outcome.usage.input_tokens == 3 + assert outcome.owner == secret.owner + + +@pytest.mark.asyncio +async def test_streaming_call_records_only_after_full_consumption(): + dao = _MockLlmEndpointsDAO() + dao.rows_by_slug["acme"] = _custom_row( + slug="acme", models=LLMModelFilter(allowlist=["gpt-4o"]), secret_id=uuid4() + ) + resolver = _MockResolver(secret=_secret()) + policy = _MockPolicy(allowed=True) + + async def _two_chunks() -> AsyncIterator[bytes]: + yield b"chunk-1" + yield b"chunk-2" + + adapter_result = LLMRelayResult(status_code=200, headers={}, body=_two_chunks()) + adapter = _MockAdapter(result=adapter_result) + registry = LLMUpstreamRegistry(adapters={"relay": adapter}) + + body = json.dumps({"model": "gpt-4o", "messages": [], "stream": True}).encode() + result = await _service( + dao=dao, resolver=resolver, registry=registry, policy=policy + ).relay_chat_completion( + scope=_scope(), + namespace=GatewayEndpointNamespace.CUSTOM, + name="acme", + body=body, + headers={}, + ) + + assert policy.record_calls == [] # nothing recorded before the body is drained + + first = await result.body.__anext__() + assert first == b"chunk-1" + assert policy.record_calls == [] # still nothing after a partial read + + remaining = [chunk async for chunk in result.body] + assert remaining == [b"chunk-2"] + assert len(policy.record_calls) == 1 # recorded exactly once, after exhaustion diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_llm_static_fields.py b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_static_fields.py new file mode 100644 index 0000000000..f17a58c658 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_llm_static_fields.py @@ -0,0 +1,136 @@ +"""Unit tests for the D40 static field rewrite (specs-wp27.md). + +Nothing running: pure functions over bytes, no I/O. +""" + +import inspect +import json + +import pytest + +from oss.src.core.gateways.llms.dtos import LLMDeploymentKind, LLMProtocol +from oss.src.core.gateways.llms.providers.passthrough.static_fields import ( + STATIC_FIELD_REWRITES, + apply_static_fields, +) + +_LITERAL_TYPES = (str, int, float, bool, type(None)) + + +def _messages_body(**extra) -> bytes: + payload = { + "model": "claude-3-5-sonnet", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "hi"}], + } + payload.update(extra) + return json.dumps(payload).encode() + + +def test_vertex_adds_anthropic_version_and_removes_model(): + result = apply_static_fields( + deployment_kind=LLMDeploymentKind.VERTEX, + protocol=LLMProtocol.MESSAGES, + body=_messages_body(), + ) + + payload = json.loads(result) + assert payload["anthropic_version"] == "vertex-2023-10-16" + assert "model" not in payload + + +def test_existing_anthropic_version_is_not_overwritten(): + """setdefault semantics, mirroring the vendor SDKs: the caller's own value stands.""" + body = _messages_body(anthropic_version="caller-supplied-value") + + result = apply_static_fields( + deployment_kind=LLMDeploymentKind.VERTEX, + protocol=LLMProtocol.MESSAGES, + body=body, + ) + + assert json.loads(result)["anthropic_version"] == "caller-supplied-value" + + +def test_non_messages_protocol_leaves_vertex_body_untouched(): + body = _messages_body() + + result = apply_static_fields( + deployment_kind=LLMDeploymentKind.VERTEX, + protocol=LLMProtocol.CHAT_COMPLETIONS, + body=body, + ) + + assert result == body + + +@pytest.mark.parametrize( + "deployment_kind", + [ + LLMDeploymentKind.DIRECT, + LLMDeploymentKind.CUSTOM, + LLMDeploymentKind.AZURE, + LLMDeploymentKind.BEDROCK, + LLMDeploymentKind.SAGEMAKER, + LLMDeploymentKind.MOCK, + ], +) +def test_every_other_deployment_kind_is_untouched_on_the_messages_door(deployment_kind): + body = _messages_body() + + result = apply_static_fields( + deployment_kind=deployment_kind, protocol=LLMProtocol.MESSAGES, body=body + ) + + assert result == body + + +def test_unparsable_body_is_returned_unchanged(): + body = b"not json" + + result = apply_static_fields( + deployment_kind=LLMDeploymentKind.VERTEX, + protocol=LLMProtocol.MESSAGES, + body=body, + ) + + assert result == body + + +def test_non_object_body_is_returned_unchanged(): + body = json.dumps([1, 2, 3]).encode() + + result = apply_static_fields( + deployment_kind=LLMDeploymentKind.VERTEX, + protocol=LLMProtocol.MESSAGES, + body=body, + ) + + assert result == body + + +def test_table_has_exactly_vertex(): + """OD19: Bedrock's entry came out — its Messages door moved to bedrock-mantle, which + needs no rewrite.""" + assert set(STATIC_FIELD_REWRITES.keys()) == {LLMDeploymentKind.VERTEX} + + +def test_table_entries_are_literal_data_only(): + """D40: 'nothing in the table may be computed from the request'. Checkable by reading + the table — every value is a plain literal, never a callable or a derived expression.""" + for rewrite in STATIC_FIELD_REWRITES.values(): + for value in rewrite.fields_added.values(): + assert isinstance(value, _LITERAL_TYPES), ( + f"fields_added value {value!r} is not a literal" + ) + assert not callable(value) + for name in rewrite.fields_removed: + assert isinstance(name, str) + + +def test_apply_static_fields_signature_cannot_see_request_semantics(): + """The function's signature is the proof (specs-wp27.md): only the table lookup keys + (`deployment_kind`, `protocol`) and the raw `body` — nothing that could carry a parsed + view of the request's content, so there is nothing for a future edit to read.""" + params = list(inspect.signature(apply_static_fields).parameters) + assert params == ["deployment_kind", "protocol", "body"] diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_mappings.py b/api/oss/tests/pytest/unit/gateways/test_gateways_mappings.py new file mode 100644 index 0000000000..4018fc265b --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_mappings.py @@ -0,0 +1,201 @@ +"""DTO <-> DBE mapping round-trips (entities.md §2, §3, §4.3, §4.4). + +Pure Python object transforms — no database, no session. `map_*_create_to_dbe` +returns a DBE that has never been flushed, so `id`/`created_at`/... are filled in +by hand here to stand in for what a real INSERT ... RETURNING would populate, +exactly the way `session.refresh()` would before a real mapping-back call. +""" + +from datetime import datetime, timezone +from uuid import uuid4 + +from oss.src.core.gateways.mcps.dtos import MCPAuthScheme, GatewayEndpointNamespace +from oss.src.core.gateways.llms.dtos import ( + LLMDeploymentKind, + LLMEndpointCreate, + LLMEndpointData, + LLMEndpointFlags, + LLMEndpointRoute, + LLMEndpointSettings, + LLMModelFilter, +) +from oss.src.core.gateways.mcps.dtos import ( + MCPEndpointSettings, + MCPEndpointCreate, + MCPEndpointData, + MCPEndpointFlags, + MCPOAuthData, + MCPEndpointRoute, + MCPToolFilter, +) +from oss.src.dbs.postgres.gateways.llms.mappings import ( + map_llm_endpoint_create_to_dbe, + map_llm_endpoint_dbe_to_dto, +) +from oss.src.dbs.postgres.gateways.mcps.mappings import ( + map_mcp_endpoint_create_to_dbe, + map_mcp_endpoint_dbe_to_dto, +) + + +def _stamp_lifecycle(dbe): + """Stand in for what a flush/refresh would populate on the row.""" + dbe.id = uuid4() + dbe.created_at = datetime.now(timezone.utc) + dbe.updated_at = None + dbe.deleted_at = None + dbe.updated_by_id = None + dbe.deleted_by_id = None + return dbe + + +# --- serialization: seed DTOs dump to the shape the mappings expect ---------- # + + +def test_llm_endpoint_data_serializes_exclude_none(): + data = LLMEndpointData( + route=LLMEndpointRoute(base_url="http://mock-llm-gateway:9091/v1"), + models=LLMModelFilter(allowlist=["gpt-4o"]), + settings=LLMEndpointSettings(max_output_tokens=4096), + ) + dumped = data.model_dump(mode="json", exclude_none=True) + assert dumped["route"]["base_url"] == "http://mock-llm-gateway:9091/v1" + assert dumped["models"]["allowlist"] == ["gpt-4o"] + assert "extras" not in dumped + + +def test_llm_endpoint_flags_serializes_exclude_none(): + flags = LLMEndpointFlags() + assert flags.model_dump(mode="json", exclude_none=True) == {"is_active": True} + + +def test_mcp_endpoint_data_serializes_exclude_none(): + data = MCPEndpointData( + route=MCPEndpointRoute(base_url="https://mcp.acme.com"), + tools=MCPToolFilter(allowlist=["search"]), + settings=MCPEndpointSettings(timeout_seconds=10.0), + oauth=MCPOAuthData(resource="https://mcp.acme.com"), + ) + dumped = data.model_dump(mode="json", exclude_none=True) + assert dumped["route"]["base_url"] == "https://mcp.acme.com" + assert dumped["tools"]["allowlist"] == ["search"] + assert dumped["oauth"]["resource"] == "https://mcp.acme.com" + + +def test_mcp_endpoint_flags_serializes_exclude_none(): + flags = MCPEndpointFlags() + assert flags.model_dump(mode="json", exclude_none=True) == { + "is_active": True, + "is_valid": True, + } + + +# --- LLM endpoint round-trip --------------------------------------------------- # + + +def test_llm_endpoint_create_round_trips_through_dbe(): + project_id = uuid4() + user_id = uuid4() + secret_id = uuid4() + + create = LLMEndpointCreate( + slug="acme-azure", + name="Acme Azure", + description="Acme's Azure OpenAI deployment_kind", + provider_key="azure", + deployment_kind=LLMDeploymentKind.AZURE, + secret_id=secret_id, + data=LLMEndpointData( + route=LLMEndpointRoute(base_url="http://mock-llm-gateway:9091/azure"), + models=LLMModelFilter(allowlist=["gpt-4o"]), + settings=LLMEndpointSettings(max_output_tokens=4096), + ), + flags=LLMEndpointFlags(is_active=True), + tags={"env": "prod"}, + meta={"note": "created by test"}, + ) + + dbe = map_llm_endpoint_create_to_dbe( + project_id=project_id, + user_id=user_id, + # + dto=create, + ) + assert dbe.project_id == project_id + assert dbe.created_by_id == user_id + _stamp_lifecycle(dbe) + + endpoint = map_llm_endpoint_dbe_to_dto(dbe=dbe) + + assert endpoint.id == dbe.id + assert endpoint.slug == create.slug + assert endpoint.name == create.name + assert endpoint.description == create.description + assert endpoint.provider_key == create.provider_key + assert endpoint.deployment_kind == create.deployment_kind + assert endpoint.secret_id == secret_id + assert endpoint.namespace == GatewayEndpointNamespace.CUSTOM + assert endpoint.data.route.base_url == create.data.route.base_url + assert endpoint.data.models.allowlist == create.data.models.allowlist + assert endpoint.data.settings.max_output_tokens == 4096 + assert endpoint.flags.is_active is True + assert endpoint.tags == create.tags + assert endpoint.meta == create.meta + assert endpoint.created_by_id == user_id + + +# --- MCP endpoint round-trip --------------------------------------------------- # + + +def test_mcp_endpoint_create_round_trips_through_dbe(): + project_id = uuid4() + user_id = uuid4() + secret_id = uuid4() + + create = MCPEndpointCreate( + slug="acme-notion", + name="Acme Notion", + description="Acme's self-hosted Notion MCP server", + auth_mode=MCPAuthScheme.OAUTH, + secret_id=secret_id, + data=MCPEndpointData( + route=MCPEndpointRoute(base_url="https://mcp.acme.com"), + tools=MCPToolFilter(allowlist=["search"]), + settings=MCPEndpointSettings(timeout_seconds=10.0), + ), + flags=MCPEndpointFlags(is_active=True), + tags={"env": "prod"}, + meta={"note": "created by test"}, + ) + + dbe = map_mcp_endpoint_create_to_dbe( + project_id=project_id, + user_id=user_id, + # + dto=create, + ) + assert dbe.project_id == project_id + assert dbe.created_by_id == user_id + _stamp_lifecycle(dbe) + + endpoint = map_mcp_endpoint_dbe_to_dto(dbe=dbe) + + assert endpoint.id == dbe.id + assert endpoint.slug == create.slug + assert endpoint.name == create.name + assert endpoint.description == create.description + assert endpoint.auth_mode == create.auth_mode + assert endpoint.secret_id == secret_id + assert endpoint.namespace == GatewayEndpointNamespace.CUSTOM + assert endpoint.connection_id is None + assert endpoint.provider_key is None + assert endpoint.integration_key is None + assert endpoint.data.route.base_url == create.data.route.base_url + assert endpoint.data.tools.allowlist == ["search"] + assert endpoint.flags.is_active is True + assert endpoint.tags == create.tags + assert endpoint.meta == create.meta + assert endpoint.created_by_id == user_id + + +# --- MCP grant round-trip ------------------------------------------------------ # diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_models.py b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_models.py new file mode 100644 index 0000000000..5176e66c34 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_models.py @@ -0,0 +1,118 @@ +"""Wire model instantiation — apis/fastapi/gateways/mcps/models.py (entities.md §6). + +C0-style: every model in the file constructs with representative values. Also confirms +""" + +from uuid import uuid4 + + +from oss.src.apis.fastapi.gateways.mcps.models import ( + MCPConnectRequest, + MCPConnectResponse, + MCPEndpointCreateRequest, + MCPEndpointEditRequest, + MCPEndpointQueryRequest, + MCPEndpointResponse, + MCPEndpointsResponse, +) +from oss.src.core.gateways.mcps.dtos import MCPAuthScheme +from oss.src.core.gateways.mcps.dtos import ( + MCPEndpoint, + MCPEndpointCreate, + MCPEndpointData, + MCPEndpointEdit, + MCPEndpointQuery, + MCPEndpointRoute, +) +from oss.src.core.shared.dtos import Windowing + + +def _endpoint_create() -> MCPEndpointCreate: + return MCPEndpointCreate( + slug="acme-notion", + auth_mode=MCPAuthScheme.NONE, + data=MCPEndpointData( + route=MCPEndpointRoute(base_url="https://mcp.acme.example/notion") + ), + ) + + +def _endpoint() -> MCPEndpoint: + return MCPEndpoint( + id=uuid4(), + slug="acme-notion", + auth_mode=MCPAuthScheme.NONE, + data=MCPEndpointData( + route=MCPEndpointRoute(base_url="https://mcp.acme.example/notion") + ), + ) + + +def test_mcp_endpoint_create_request_instantiates(): + request = MCPEndpointCreateRequest(endpoint=_endpoint_create()) + assert request.endpoint.slug == "acme-notion" + + +def test_mcp_endpoint_edit_request_instantiates(): + request = MCPEndpointEditRequest( + endpoint=MCPEndpointEdit( + id=uuid4(), + auth_mode=MCPAuthScheme.NONE, + data=MCPEndpointData( + route=MCPEndpointRoute(base_url="https://mcp.acme.example/notion") + ), + ) + ) + assert request.endpoint.data.route.base_url == "https://mcp.acme.example/notion" + + +def test_mcp_endpoint_query_request_instantiates_with_defaults(): + request = MCPEndpointQueryRequest() + assert request.endpoint is None + assert request.windowing is None + + +def test_mcp_endpoint_query_request_instantiates_with_values(): + request = MCPEndpointQueryRequest( + endpoint=MCPEndpointQuery(slug="acme-notion"), + windowing=Windowing(limit=10), + ) + assert request.endpoint.slug == "acme-notion" + assert request.windowing.limit == 10 + + +def test_mcp_endpoint_response_instantiates(): + response = MCPEndpointResponse(count=1, endpoint=_endpoint()) + assert response.count == 1 + assert response.endpoint.slug == "acme-notion" + + +def test_mcp_endpoints_response_instantiates(): + response = MCPEndpointsResponse(count=1, endpoints=[_endpoint()]) + assert len(response.endpoints) == 1 + + +def test_mcp_endpoints_response_default_list_is_not_shared(): + first = MCPEndpointsResponse() + second = MCPEndpointsResponse() + first.endpoints.append(_endpoint()) + assert second.endpoints == [] + + +def test_mcp_connect_request_instantiates(): + request = MCPConnectRequest(scopes=["read", "write"]) + assert request.scopes == ["read", "write"] + + +def test_mcp_connect_request_default_scopes_is_none(): + """`scopes: None` (absent) is the discover step, not an empty-list default + (specs-wp18.md) — WP17's own scaffold used a shared-list default; WP18 changes + the semantics on purpose so a caller can distinguish "haven't chosen yet" from + "chose nothing".""" + request = MCPConnectRequest() + assert request.scopes is None + + +def test_mcp_connect_response_instantiates(): + response = MCPConnectResponse(count=1, redirect_url="https://example.com/oauth") + assert response.redirect_url == "https://example.com/oauth" diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_client.py b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_client.py new file mode 100644 index 0000000000..95a5c9a5c8 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_client.py @@ -0,0 +1,312 @@ +"""Unit tests for `MCPOAuthClient` (specs-wp17.md "Why not OAuthClientProvider"). + +`httpx.MockTransport` stands in for a mock authorization server throughout — no real +network, no real authorization server, no real MCP server (matching +`test_gateways_http_mcp_adapter.py`'s and `test_provider_probe.py`'s existing pattern). +""" + +from __future__ import annotations + +import json + +import httpx +import pytest +from mcp.shared.auth import OAuthClientInformationFull + +from oss.src.core.gateways.mcps.oauth.client import MCPOAuthClient +from oss.src.core.gateways.mcps.oauth.types import ( + MCPOAuthDiscoveryError, + MCPOAuthRegistrationError, + MCPOAuthTokenExchangeError, +) + +_PRM = { + "resource": "https://mcp.acme.io/", + "authorization_servers": ["https://auth.acme.io/"], + "scopes_supported": ["read", "write"], +} +_AS_METADATA = { + "issuer": "https://auth.acme.io/", + "authorization_endpoint": "https://auth.acme.io/authorize", + "token_endpoint": "https://auth.acme.io/token", + "registration_endpoint": "https://auth.acme.io/register", + "scopes_supported": ["read", "write"], +} + + +def _mock_as_handler(*, registration_status=201, token_status=200, token_body=None): + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/.well-known/oauth-protected-resource": + return httpx.Response(200, json=_PRM) + if path == "/.well-known/oauth-authorization-server": + return httpx.Response(200, json=_AS_METADATA) + if path == "/register": + return httpx.Response( + registration_status, + json={ + **json.loads(request.content), + "client_id": "client-abc", + "client_secret": "secret-abc", + }, + ) + if path == "/token": + return httpx.Response( + token_status, + json=token_body + or { + "access_token": "tok-xyz", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + return httpx.Response(404) + + return handler + + +@pytest.mark.asyncio +async def test_discover_returns_metadata_from_the_mock_server(): + client = MCPOAuthClient(transport=httpx.MockTransport(_mock_as_handler())) + + discovery = await client.discover(server_url="https://mcp.acme.io/") + + assert discovery.resource == "https://mcp.acme.io/" + assert discovery.authorization_server == "https://auth.acme.io/" + assert discovery.authorization_endpoint == "https://auth.acme.io/authorize" + assert discovery.token_endpoint == "https://auth.acme.io/token" + assert discovery.registration_endpoint == "https://auth.acme.io/register" + assert set(discovery.scopes_offered) == {"read", "write"} + + +@pytest.mark.asyncio +async def test_discover_raises_when_every_well_known_url_404s(): + client = MCPOAuthClient( + transport=httpx.MockTransport(lambda request: httpx.Response(404)) + ) + + with pytest.raises(MCPOAuthDiscoveryError): + await client.discover(server_url="https://mcp.acme.io/") + + +def _mock_hidden_prm_handler(): + """PRM lives only at `/secret/prm`, named by the 401's `WWW-Authenticate` header. + + Every well-known path 404s. A well-known-only client cannot discover this server; + that is the OD21 gap this handler exists to prove. + """ + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/" and "Authorization" not in request.headers: + return httpx.Response( + 401, + headers={ + "WWW-Authenticate": ( + 'Bearer resource_metadata="https://mcp.acme.io/secret/prm"' + ) + }, + ) + if path == "/secret/prm": + return httpx.Response(200, json=_PRM) + if path == "/.well-known/oauth-authorization-server": + return httpx.Response(200, json=_AS_METADATA) + return httpx.Response(404) + + return handler + + +@pytest.mark.asyncio +async def test_discover_reads_resource_metadata_from_the_401_www_authenticate_header(): + """The gap OD21 closes: PRM at an unguessable path, found only via the 401 header.""" + client = MCPOAuthClient(transport=httpx.MockTransport(_mock_hidden_prm_handler())) + + discovery = await client.discover(server_url="https://mcp.acme.io/") + + assert discovery.resource == "https://mcp.acme.io/" + assert discovery.authorization_server == "https://auth.acme.io/" + + +@pytest.mark.asyncio +async def test_discover_falls_back_to_well_known_when_401_has_no_www_authenticate(): + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/" and "Authorization" not in request.headers: + return httpx.Response(401) + if path == "/.well-known/oauth-protected-resource": + return httpx.Response(200, json=_PRM) + if path == "/.well-known/oauth-authorization-server": + return httpx.Response(200, json=_AS_METADATA) + return httpx.Response(404) + + client = MCPOAuthClient(transport=httpx.MockTransport(handler)) + + discovery = await client.discover(server_url="https://mcp.acme.io/") + + assert discovery.resource == "https://mcp.acme.io/" + + +@pytest.mark.asyncio +async def test_discover_falls_back_to_well_known_when_header_has_no_resource_metadata(): + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/" and "Authorization" not in request.headers: + return httpx.Response(401, headers={"WWW-Authenticate": "Bearer"}) + if path == "/.well-known/oauth-protected-resource": + return httpx.Response(200, json=_PRM) + if path == "/.well-known/oauth-authorization-server": + return httpx.Response(200, json=_AS_METADATA) + return httpx.Response(404) + + client = MCPOAuthClient(transport=httpx.MockTransport(handler)) + + discovery = await client.discover(server_url="https://mcp.acme.io/") + + assert discovery.resource == "https://mcp.acme.io/" + + +@pytest.mark.asyncio +async def test_discover_falls_back_to_well_known_when_header_url_404s(): + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/" and "Authorization" not in request.headers: + return httpx.Response( + 401, + headers={ + "WWW-Authenticate": 'Bearer resource_metadata="https://mcp.acme.io/gone"' + }, + ) + if path == "/gone": + return httpx.Response(404) + if path == "/.well-known/oauth-protected-resource": + return httpx.Response(200, json=_PRM) + if path == "/.well-known/oauth-authorization-server": + return httpx.Response(200, json=_AS_METADATA) + return httpx.Response(404) + + client = MCPOAuthClient(transport=httpx.MockTransport(handler)) + + discovery = await client.discover(server_url="https://mcp.acme.io/") + + assert discovery.resource == "https://mcp.acme.io/" + + +@pytest.mark.asyncio +async def test_discover_raises_a_discovery_error_not_a_registration_error_when_header_url_404s_and_no_fallback(): + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/" and "Authorization" not in request.headers: + return httpx.Response( + 401, + headers={ + "WWW-Authenticate": 'Bearer resource_metadata="https://mcp.acme.io/gone"' + }, + ) + return httpx.Response(404) + + client = MCPOAuthClient(transport=httpx.MockTransport(handler)) + + with pytest.raises(MCPOAuthDiscoveryError): + await client.discover(server_url="https://mcp.acme.io/") + + +@pytest.mark.asyncio +async def test_register_posts_metadata_and_returns_client_info(): + client = MCPOAuthClient(transport=httpx.MockTransport(_mock_as_handler())) + + info = await client.register( + authorization_server="https://auth.acme.io/", + registration_endpoint="https://auth.acme.io/register", + redirect_uri="https://api.agenta.ai/gateways/mcps/connect/callback", + scopes=["read", "write"], + ) + + assert info.client_id == "client-abc" + assert info.client_secret == "secret-abc" + + +@pytest.mark.asyncio +async def test_register_raises_on_non_2xx(): + client = MCPOAuthClient( + transport=httpx.MockTransport(_mock_as_handler(registration_status=400)) + ) + + with pytest.raises(MCPOAuthRegistrationError): + await client.register( + authorization_server="https://auth.acme.io/", + registration_endpoint="https://auth.acme.io/register", + redirect_uri="https://api.agenta.ai/gateways/mcps/connect/callback", + scopes=[], + ) + + +@pytest.mark.asyncio +async def test_exchange_token_returns_the_token_on_success(): + client = MCPOAuthClient(transport=httpx.MockTransport(_mock_as_handler())) + client_info = OAuthClientInformationFull( + redirect_uris=["https://api.agenta.ai/gateways/mcps/connect/callback"], + client_id="client-abc", + client_secret="secret-abc", + ) + + token = await client.exchange_token( + token_endpoint="https://auth.acme.io/token", + code="auth-code-1", + code_verifier="a" * 43, + redirect_uri="https://api.agenta.ai/gateways/mcps/connect/callback", + client_info=client_info, + ) + + assert token.access_token == "tok-xyz" + assert token.expires_in == 3600 + + +@pytest.mark.asyncio +async def test_exchange_token_raises_on_error_response(): + client = MCPOAuthClient( + transport=httpx.MockTransport(_mock_as_handler(token_status=400)) + ) + client_info = OAuthClientInformationFull( + redirect_uris=["https://api.agenta.ai/gateways/mcps/connect/callback"], + client_id="client-abc", + ) + + with pytest.raises(MCPOAuthTokenExchangeError): + await client.exchange_token( + token_endpoint="https://auth.acme.io/token", + code="bad-code", + code_verifier="a" * 43, + redirect_uri="https://api.agenta.ai/gateways/mcps/connect/callback", + client_info=client_info, + ) + + +def test_build_pkce_generates_verifier_and_challenge(): + client = MCPOAuthClient() + + pkce = client.build_pkce() + + assert 43 <= len(pkce.code_verifier) <= 128 + assert pkce.code_challenge + + +def test_authorization_url_carries_the_fixed_redirect_uri_and_pkce(): + client = MCPOAuthClient() + + url = client.authorization_url( + authorization_endpoint="https://auth.acme.io/authorize", + client_id="client-abc", + redirect_uri="https://api.agenta.ai/gateways/mcps/connect/callback", + code_challenge="challenge-xyz", + state="state-token", + scopes=["read", "write"], + resource="https://mcp.acme.io/", + ) + + assert url.startswith("https://auth.acme.io/authorize?") + assert "client_id=client-abc" in url + assert "code_challenge=challenge-xyz" in url + assert "code_challenge_method=S256" in url + assert "state=state-token" in url + assert "scope=read+write" in url + assert "resource=https%3A%2F%2Fmcp.acme.io%2F" in url diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_registration.py b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_registration.py new file mode 100644 index 0000000000..52b582624c --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_registration.py @@ -0,0 +1,103 @@ +"""Unit tests for the registration-strategy detector (specs-wp20.md "The detector"). + +Pure functions, an injected resolver — no DNS, no network, no mock authorization +server needed for these. +""" + +from oss.src.core.gateways.mcps.oauth.registration import ( + client_metadata_document, + client_metadata_url, + identity_document_client_info, + is_publicly_resolvable, +) + +_API_URL = "https://api.acme.internal" + + +def test_client_metadata_url_is_fixed_and_deployment_wide(): + url = client_metadata_url(api_url=_API_URL) + + assert url == "https://api.acme.internal/gateways/mcps/oauth/client-metadata.json" + + +def test_client_metadata_document_carries_the_fixed_redirect_and_no_secret(): + document = client_metadata_document( + api_url=_API_URL, + redirect_uri="https://api.acme.internal/gateways/mcps/connect/callback", + ) + + assert [str(u) for u in document.redirect_uris] == [ + "https://api.acme.internal/gateways/mcps/connect/callback" + ] + assert document.token_endpoint_auth_method == "none" + assert not hasattr(document, "client_secret") + + +def test_identity_document_client_info_is_deterministic(): + redirect_uri = "https://api.acme.internal/gateways/mcps/connect/callback" + + first = identity_document_client_info(api_url=_API_URL, redirect_uri=redirect_uri) + second = identity_document_client_info(api_url=_API_URL, redirect_uri=redirect_uri) + + assert first.client_id == second.client_id == client_metadata_url(api_url=_API_URL) + assert first.client_secret is None + + +def test_public_address_is_detected_as_resolvable(): + assert is_publicly_resolvable(_API_URL, resolve=lambda _h: ["1.1.1.1"]) is True + + +def test_private_address_is_detected_as_not_resolvable(): + assert is_publicly_resolvable(_API_URL, resolve=lambda _h: ["10.0.0.5"]) is False + + +def test_mixed_public_and_private_addresses_falls_to_not_resolvable(): + """Conservative by construction: one private address among several is enough to + fall back, because a wrong "resolvable" answer is the direction that fails + silently on the authorization server's side (specs-wp20.md).""" + assert ( + is_publicly_resolvable(_API_URL, resolve=lambda _h: ["1.1.1.1", "10.0.0.5"]) + is False + ) + + +def test_a_resolution_failure_falls_to_not_resolvable(): + def _raise(_hostname: str): + raise OSError("name or service not known") + + assert is_publicly_resolvable(_API_URL, resolve=_raise) is False + + +def test_an_empty_answer_falls_to_not_resolvable(): + assert is_publicly_resolvable(_API_URL, resolve=lambda _h: []) is False + + +def test_http_scheme_never_attempts_the_document_regardless_of_the_resolver(): + assert ( + is_publicly_resolvable( + "http://api.acme.internal", resolve=lambda _h: ["1.1.1.1"] + ) + is False + ) + + +def test_split_horizon_dns_wrong_in_the_safe_direction(): + """A hostname that is genuinely public but whose local/internal resolver answers + with a private address (a real split-horizon shape) is misdetected as internal. + That is "wrong" but harmless: the outbound path WP17 already ships is unaffected + by which client mechanism produced its registration (specs-wp20.md "Wrong in + each direction, direction 2").""" + internal_resolver_view = lambda _h: ["10.0.0.5"] # noqa: E731 + + assert is_publicly_resolvable(_API_URL, resolve=internal_resolver_view) is False + + +def test_a_positive_answer_cannot_distinguish_reachable_from_merely_public_looking(): + """The detector documents its own blind spot rather than hiding it: a resolved + address that classifies as public (not private/loopback/link-local/reserved/ + multicast/unspecified) is treated as resolvable even though public IP space can + still be firewalled or NAT'd in a way DNS alone cannot reveal. Nothing on our + side observes that failure (specs-wp20.md "Wrong in each direction, direction + 1") — this test pins the fact that the detector proceeds on DNS evidence alone, + it does not attempt to verify reachability beyond it.""" + assert is_publicly_resolvable(_API_URL, resolve=lambda _h: ["8.8.8.8"]) is True diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_registration_fallback.py b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_registration_fallback.py new file mode 100644 index 0000000000..1a699b8732 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_registration_fallback.py @@ -0,0 +1,251 @@ +"""Unit tests for the two-strategy registration swap (specs-wp20.md). + +Same mock-authorization-server-behind-`httpx.MockTransport` pattern as +`test_gateways_mcp_oauth_service.py`, with the resolver also injected — no DNS, no +network, no real authorization server. +""" + +from typing import List, Tuple +from urllib.parse import parse_qs, urlparse +from uuid import UUID, uuid4 + +import httpx +import pytest + +from oss.src.core.gateways.mcps.oauth.client import MCPOAuthClient +from oss.src.core.gateways.mcps.oauth.registration import client_metadata_url +from oss.src.core.gateways.mcps.oauth.service import MCPOAuthConnectService +from oss.src.core.gateways.mcps.oauth.state import decode_state +from oss.src.core.secrets.dtos import SecretResponseDTO +from oss.src.core.secrets.services import VaultService + +_SECRET_KEY = "unit-test-crypt-key" +_API_URL = "https://api.agenta.ai" +_SERVER_URL = "https://mcp.acme.io/" +_AS_BASE = "https://auth.acme.io" + +_PRM = { + "resource": _SERVER_URL, + "authorization_servers": [f"{_AS_BASE}/"], + "scopes_supported": ["read", "write"], +} +_AS_METADATA = { + "issuer": f"{_AS_BASE}/", + "authorization_endpoint": f"{_AS_BASE}/authorize", + "token_endpoint": f"{_AS_BASE}/token", + "registration_endpoint": f"{_AS_BASE}/register", + "scopes_supported": ["read", "write"], +} + + +class _FakeSecretsDAO: + def __init__(self) -> None: + self.records: List[Tuple[UUID, SecretResponseDTO]] = [] + + def _scoped(self, project_id) -> List[SecretResponseDTO]: + return [r for p, r in self.records if p == project_id] + + async def create(self, *, project_id=None, organization_id=None, create_secret_dto): + record = SecretResponseDTO( + id=uuid4(), + slug=create_secret_dto.slug, + kind=create_secret_dto.secret.kind, + data=create_secret_dto.secret.data.model_dump(exclude_none=True), + header=create_secret_dto.header, + ) + self.records.append((project_id, record)) + return record + + async def get_by_id(self, secret_id, project_id=None, organization_id=None): + return next((r for r in self._scoped(project_id) if r.id == secret_id), None) + + async def get_by_slug(self, secret_slug, project_id=None, organization_id=None): + return next( + (r for r in self._scoped(project_id) if r.slug == secret_slug), None + ) + + async def list(self, project_id=None, organization_id=None): + return self._scoped(project_id) + + async def update( + self, + secret_id, + update_secret_dto, + project_id=None, + organization_id=None, + user_id=None, + ): + scoped = self._scoped(project_id) + stored = next((r for r in scoped if r.id == secret_id), None) + if stored is None: + return None + record = SecretResponseDTO( + id=stored.id, + slug=stored.slug, + kind=stored.kind, + data=update_secret_dto.secret.data.model_dump(exclude_none=True), + header=update_secret_dto.header or stored.header, + ) + idx = self.records.index((project_id, stored)) + self.records[idx] = (project_id, record) + return record + + async def delete(self, secret_id, project_id=None, organization_id=None): + self.records = [ + (p, r) + for p, r in self.records + if not (p == project_id and r.id == secret_id) + ] + + +def _mock_as_handler(*, register_called: list): + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/.well-known/oauth-protected-resource": + return httpx.Response(200, json=_PRM) + if path == "/.well-known/oauth-authorization-server": + return httpx.Response(200, json=_AS_METADATA) + if path == "/register": + register_called.append(True) + import json as _json + + return httpx.Response( + 201, + json={ + **_json.loads(request.content), + "client_id": "client-abc", + "client_secret": "secret-abc", + }, + ) + if path == "/token": + return httpx.Response( + 200, + json={ + "access_token": "tok-xyz", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read write", + }, + ) + return httpx.Response(404) + + return handler + + +def _service( + *, resolve, register_called=None +) -> Tuple[MCPOAuthConnectService, _FakeSecretsDAO]: + register_called = register_called if register_called is not None else [] + dao = _FakeSecretsDAO() + vault = VaultService(secrets_dao=dao) + client = MCPOAuthClient( + transport=httpx.MockTransport(_mock_as_handler(register_called=register_called)) + ) + service = MCPOAuthConnectService( + vault_service=vault, + client=client, + api_url=_API_URL, + secret_key=_SECRET_KEY, + resolve=resolve, + ) + return service, dao + + +@pytest.mark.asyncio +async def test_begin_prefers_the_identity_document_when_publicly_resolvable(): + register_called: list = [] + service, dao = _service( + resolve=lambda _h: ["1.1.1.1"], register_called=register_called + ) + project_id, user_id = uuid4(), uuid4() + + start = await service.begin( + project_id=project_id, user_id=user_id, server_url=_SERVER_URL, scopes=["read"] + ) + + params = parse_qs(urlparse(start.authorization_url).query) + assert params["client_id"][0] == client_metadata_url(api_url=_API_URL) + assert register_called == [] + assert not [r for _, r in dao.records if r.kind.value == "oauth_provider"] + + payload = decode_state(start.state, secret_key=_SECRET_KEY) + assert payload is not None + assert payload["strategy"] == "document" + + +@pytest.mark.asyncio +async def test_begin_falls_back_to_outbound_registration_when_not_publicly_resolvable(): + register_called: list = [] + service, dao = _service( + resolve=lambda _h: ["10.0.0.5"], register_called=register_called + ) + project_id, user_id = uuid4(), uuid4() + + start = await service.begin( + project_id=project_id, user_id=user_id, server_url=_SERVER_URL, scopes=["read"] + ) + + params = parse_qs(urlparse(start.authorization_url).query) + assert params["client_id"][0] == "client-abc" + assert register_called == [True] + assert [r for _, r in dao.records if r.kind.value == "oauth_provider"] + + payload = decode_state(start.state, secret_key=_SECRET_KEY) + assert payload is not None + assert payload["strategy"] == "outbound" + + +@pytest.mark.asyncio +async def test_complete_via_the_identity_document_needs_no_stored_client_info(): + service, dao = _service(resolve=lambda _h: ["1.1.1.1"]) + project_id, user_id = uuid4(), uuid4() + + start = await service.begin( + project_id=project_id, user_id=user_id, server_url=_SERVER_URL, scopes=["read"] + ) + completion = await service.complete(code="auth-code-1", state=start.state) + + grant_rows = [r for _, r in dao.records if r.kind.value == "oauth_grant"] + assert len(grant_rows) == 1 + assert grant_rows[0].id == completion.secret_id + assert not [r for _, r in dao.records if r.kind.value == "oauth_provider"] + + +@pytest.mark.asyncio +async def test_a_second_connect_reprobes_and_keeps_using_the_document_when_still_resolvable(): + service, dao = _service(resolve=lambda _h: ["1.1.1.1"]) + project_id, user_id = uuid4(), uuid4() + + await service.begin( + project_id=project_id, user_id=user_id, server_url=_SERVER_URL, scopes=["read"] + ) + start2 = await service.begin( + project_id=project_id, user_id=user_id, server_url=_SERVER_URL, scopes=["write"] + ) + + params = parse_qs(urlparse(start2.authorization_url).query) + assert params["client_id"][0] == client_metadata_url(api_url=_API_URL) + assert not [r for _, r in dao.records if r.kind.value == "oauth_provider"] + + +@pytest.mark.asyncio +async def test_wrong_direction_2_split_horizon_still_completes_a_full_authorization(): + """specs-wp20.md "Wrong in each direction, direction 2": a hostname the detector + misreads as internal (a private-looking resolver answer for a domain that is + really public) simply takes WP17's always-safe outbound path. The flow still + completes end to end — this is the harmless direction.""" + register_called: list = [] + service, dao = _service( + resolve=lambda _h: ["10.0.0.5"], register_called=register_called + ) + project_id, user_id = uuid4(), uuid4() + + start = await service.begin( + project_id=project_id, user_id=user_id, server_url=_SERVER_URL, scopes=["read"] + ) + completion = await service.complete(code="auth-code-1", state=start.state) + + assert register_called == [True] + assert completion.secret_id is not None + grant_rows = [r for _, r in dao.records if r.kind.value == "oauth_grant"] + assert grant_rows[0].data.grant.access_token == "tok-xyz" diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_router.py b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_router.py new file mode 100644 index 0000000000..4739b266e6 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_router.py @@ -0,0 +1,47 @@ +"""Router wiring — apis/fastapi/gateways/mcps/oauth_router.py (specs-wp20.md). + +TestClient against a bare FastAPI app carrying only this router — no auth middleware, +no database. The auth-exemption itself (`middlewares/auth.py`'s `_PUBLIC_ENDPOINTS`) +is a one-line addition verified by inspection, not a live-middleware test. +""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from oss.src.apis.fastapi.gateways.mcps.oauth_router import MCPOAuthClientMetadataRouter +from oss.src.core.gateways.mcps.oauth.registration import client_metadata_url +from oss.src.core.gateways.mcps.oauth.service import callback_redirect_uri +from oss.src.utils.env import env + + +def _client() -> TestClient: + router = MCPOAuthClientMetadataRouter() + app = FastAPI() + app.include_router(router=router.router, prefix="/gateways/mcps") + return TestClient(app) + + +def test_serves_a_client_metadata_document_with_no_auth_header(): + response = _client().get("/gateways/mcps/oauth/client-metadata.json") + + assert response.status_code == 200 + body = response.json() + assert body["token_endpoint_auth_method"] == "none" + assert "client_secret" not in body + assert body["redirect_uris"] == [callback_redirect_uri(api_url=env.agenta.api_url)] + + +def test_the_served_document_has_no_client_id_field(): + """The client_id is the document's own URL (specs-wp20.md); it is never a field + inside the document itself.""" + body = _client().get("/gateways/mcps/oauth/client-metadata.json").json() + + assert "client_id" not in body + + +def test_client_metadata_url_matches_the_route_the_router_serves(): + from oss.src.utils.env import env + + assert client_metadata_url(api_url=env.agenta.api_url).endswith( + "/gateways/mcps/oauth/client-metadata.json" + ) diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_service.py b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_service.py new file mode 100644 index 0000000000..b7b5c35908 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_service.py @@ -0,0 +1,378 @@ +"""Unit tests for `MCPOAuthConnectService` (specs-wp17.md "The two-phase connect +service"). + +A mock authorization server behind `httpx.MockTransport`, a real `VaultService` over an +in-memory fake DAO — no real network, no real authorization server, no real MCP server. +""" + +from __future__ import annotations + +from typing import List, Tuple +from urllib.parse import parse_qs, urlparse +from uuid import UUID, uuid4 + +import httpx +import pytest + +from oss.src.core.gateways.mcps.oauth.client import MCPOAuthClient +from oss.src.core.gateways.mcps.oauth.service import ( + MCPOAuthConnectService, + callback_redirect_uri, +) +from oss.src.core.gateways.mcps.oauth.state import decode_state +from oss.src.core.gateways.mcps.oauth.types import ( + MCPOAuthClientNotRegisteredError, + MCPOAuthStateInvalidError, +) +from oss.src.core.secrets.dtos import SecretResponseDTO +from oss.src.core.secrets.services import VaultService + +_SECRET_KEY = "unit-test-crypt-key" +_API_URL = "https://api.agenta.ai" +_SERVER_URL = "https://mcp.acme.io/" +_AS_BASE = "https://auth.acme.io" + +_PRM = { + "resource": _SERVER_URL, + "authorization_servers": [f"{_AS_BASE}/"], + "scopes_supported": ["read", "write"], +} +_AS_METADATA = { + "issuer": f"{_AS_BASE}/", + "authorization_endpoint": f"{_AS_BASE}/authorize", + "token_endpoint": f"{_AS_BASE}/token", + "registration_endpoint": f"{_AS_BASE}/register", + "scopes_supported": ["read", "write"], +} + + +class _FakeSecretsDAO: + def __init__(self) -> None: + self.records: List[Tuple[UUID, SecretResponseDTO]] = [] + + def _scoped(self, project_id) -> List[SecretResponseDTO]: + return [r for p, r in self.records if p == project_id] + + async def create(self, *, project_id=None, organization_id=None, create_secret_dto): + record = SecretResponseDTO( + id=uuid4(), + slug=create_secret_dto.slug, + kind=create_secret_dto.secret.kind, + data=create_secret_dto.secret.data.model_dump(exclude_none=True), + header=create_secret_dto.header, + ) + self.records.append((project_id, record)) + return record + + async def get_by_id(self, secret_id, project_id=None, organization_id=None): + return next((r for r in self._scoped(project_id) if r.id == secret_id), None) + + async def get_by_slug(self, secret_slug, project_id=None, organization_id=None): + return next( + (r for r in self._scoped(project_id) if r.slug == secret_slug), None + ) + + async def list(self, project_id=None, organization_id=None): + return self._scoped(project_id) + + async def update( + self, + secret_id, + update_secret_dto, + project_id=None, + organization_id=None, + user_id=None, + ): + scoped = self._scoped(project_id) + stored = next((r for r in scoped if r.id == secret_id), None) + if stored is None: + return None + record = SecretResponseDTO( + id=stored.id, + slug=stored.slug, + kind=stored.kind, + data=update_secret_dto.secret.data.model_dump(exclude_none=True), + header=update_secret_dto.header or stored.header, + ) + idx = self.records.index((project_id, stored)) + self.records[idx] = (project_id, record) + return record + + async def delete(self, secret_id, project_id=None, organization_id=None): + self.records = [ + (p, r) + for p, r in self.records + if not (p == project_id and r.id == secret_id) + ] + + +def _mock_as_handler(*, token_access_token="tok-xyz"): + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/.well-known/oauth-protected-resource": + return httpx.Response(200, json=_PRM) + if path == "/.well-known/oauth-authorization-server": + return httpx.Response(200, json=_AS_METADATA) + if path == "/register": + import json as _json + + return httpx.Response( + 201, + json={ + **_json.loads(request.content), + "client_id": "client-abc", + "client_secret": "secret-abc", + }, + ) + if path == "/token": + return httpx.Response( + 200, + json={ + "access_token": token_access_token, + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read write", + }, + ) + return httpx.Response(404) + + return handler + + +def _private_resolve(_hostname: str) -> List[str]: + """WP17's own tests predate the identity-document strategy (specs-wp20.md) and + assert the outbound path throughout — pin detection to "internal" so none of them + starts resolving `_API_URL` over real DNS.""" + return ["10.0.0.5"] + + +def _service( + *, dao=None, handler=None, resolve=_private_resolve +) -> Tuple[MCPOAuthConnectService, _FakeSecretsDAO]: + dao = dao or _FakeSecretsDAO() + vault = VaultService(secrets_dao=dao) + client = MCPOAuthClient( + transport=httpx.MockTransport(handler or _mock_as_handler()) + ) + service = MCPOAuthConnectService( + vault_service=vault, + client=client, + api_url=_API_URL, + secret_key=_SECRET_KEY, + resolve=resolve, + ) + return service, dao + + +def test_callback_redirect_uri_is_fixed_and_carries_no_query(): + uri = callback_redirect_uri(api_url=_API_URL) + + assert uri == "https://api.agenta.ai/gateways/mcps/connect/callback" + assert "?" not in uri + + +@pytest.mark.asyncio +async def test_discover_returns_the_client_discovery_shape(): + service, _dao = _service() + + discovery = await service.discover(server_url=_SERVER_URL) + + assert discovery.authorization_server == f"{_AS_BASE}/" + assert set(discovery.scopes_offered) == {"read", "write"} + + +@pytest.mark.asyncio +async def test_begin_returns_an_authorization_url_with_the_fixed_redirect_uri_and_pkce(): + service, dao = _service() + project_id, user_id = uuid4(), uuid4() + + start = await service.begin( + project_id=project_id, user_id=user_id, server_url=_SERVER_URL, scopes=["read"] + ) + + parsed = urlparse(start.authorization_url) + params = parse_qs(parsed.query) + assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == f"{_AS_BASE}/authorize" + assert params["redirect_uri"][0] == callback_redirect_uri(api_url=_API_URL) + assert params["client_id"][0] == "client-abc" + assert params["code_challenge_method"][0] == "S256" + assert "code_challenge" in params + assert params["scope"][0] == "read" + + # Client registration was written — proves get-or-create ran, not a bare pass-through. + assert any(r.slug and "oauth-provider" in r.slug for _, r in dao.records) + + +@pytest.mark.asyncio +async def test_begin_state_decodes_to_the_right_identity_and_verifier(): + service, _dao = _service() + project_id, user_id = uuid4(), uuid4() + + start = await service.begin( + project_id=project_id, user_id=user_id, server_url=_SERVER_URL, scopes=["read"] + ) + payload = decode_state(start.state, secret_key=_SECRET_KEY) + + assert payload is not None + assert payload["project_id"] == str(project_id) + assert payload["user_id"] == str(user_id) + assert payload["server_url"] == _SERVER_URL + assert len(payload["code_verifier"]) >= 43 + + +@pytest.mark.asyncio +async def test_begin_reuses_client_registration_on_a_second_call(): + service, dao = _service() + project_id, user_id = uuid4(), uuid4() + + await service.begin( + project_id=project_id, user_id=user_id, server_url=_SERVER_URL, scopes=["read"] + ) + await service.begin( + project_id=project_id, user_id=user_id, server_url=_SERVER_URL, scopes=["write"] + ) + + provider_rows = [r for _, r in dao.records if r.kind.value == "oauth_provider"] + assert len(provider_rows) == 1 + + +@pytest.mark.asyncio +async def test_complete_with_valid_code_and_state_writes_an_oauth_grant_and_returns_its_id(): + service, dao = _service() + project_id, user_id = uuid4(), uuid4() + + start = await service.begin( + project_id=project_id, user_id=user_id, server_url=_SERVER_URL, scopes=["read"] + ) + completion = await service.complete(code="auth-code-1", state=start.state) + + assert completion.project_id == project_id + assert completion.server_url == _SERVER_URL + grant_rows = [r for _, r in dao.records if r.kind.value == "oauth_grant"] + assert len(grant_rows) == 1 + assert grant_rows[0].id == completion.secret_id + assert grant_rows[0].data.grant.access_token == "tok-xyz" + + +@pytest.mark.asyncio +async def test_complete_with_tampered_state_raises_before_any_http_call(): + service, _dao = _service() + project_id, user_id = uuid4(), uuid4() + + start = await service.begin( + project_id=project_id, user_id=user_id, server_url=_SERVER_URL, scopes=["read"] + ) + tampered = start.state[:-1] + ("0" if start.state[-1] != "0" else "1") + + with pytest.raises(MCPOAuthStateInvalidError): + await service.complete(code="auth-code-1", state=tampered) + + +@pytest.mark.asyncio +async def test_complete_with_expired_state_raises(): + service, _dao = _service() + project_id, user_id = uuid4(), uuid4() + + start = await service.begin( + project_id=project_id, user_id=user_id, server_url=_SERVER_URL, scopes=["read"] + ) + payload = decode_state(start.state, secret_key=_SECRET_KEY) + assert payload is not None + payload["ts"] = 0 # decades ago + import base64 + import hashlib + import hmac + import json + + payload_b64 = ( + base64.urlsafe_b64encode(json.dumps(payload, sort_keys=True).encode()) + .decode() + .rstrip("=") + ) + sig = hmac.new( + _SECRET_KEY.encode(), payload_b64.encode(), hashlib.sha256 + ).hexdigest() + expired_state = f"{payload_b64}.{sig}" + + with pytest.raises(MCPOAuthStateInvalidError): + await service.complete(code="auth-code-1", state=expired_state) + + +@pytest.mark.asyncio +async def test_complete_without_a_prior_registration_raises_client_not_registered(): + service, _dao = _service() + from oss.src.core.gateways.mcps.oauth.state import make_state + + state = make_state( + project_id=uuid4(), + user_id=uuid4(), + server_url=_SERVER_URL, + code_verifier="a" * 43, + scopes=["read"], + secret_key=_SECRET_KEY, + ) + + with pytest.raises(MCPOAuthClientNotRegisteredError): + await service.complete(code="auth-code-1", state=state) + + +@pytest.mark.asyncio +async def test_complete_with_token_endpoint_error_raises_typed_exception(): + def failing_token_handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/.well-known/oauth-protected-resource": + return httpx.Response(200, json=_PRM) + if request.url.path == "/.well-known/oauth-authorization-server": + return httpx.Response(200, json=_AS_METADATA) + if request.url.path == "/register": + import json as _json + + return httpx.Response( + 201, + json={ + **_json.loads(request.content), + "client_id": "client-abc", + "client_secret": "secret-abc", + }, + ) + if request.url.path == "/token": + return httpx.Response(400, json={"error": "invalid_grant"}) + return httpx.Response(404) + + service, _dao = _service(handler=failing_token_handler) + project_id, user_id = uuid4(), uuid4() + + start = await service.begin( + project_id=project_id, user_id=user_id, server_url=_SERVER_URL, scopes=["read"] + ) + + from oss.src.core.gateways.mcps.oauth.types import MCPOAuthTokenExchangeError + + with pytest.raises(MCPOAuthTokenExchangeError): + await service.complete(code="auth-code-1", state=start.state) + + +@pytest.mark.asyncio +async def test_step_up_reuses_the_same_grant_row_rather_than_creating_a_second_one(): + """WP19's seam (specs-wp17.md): a second begin()/complete() for the same + server_url with a narrower scopes list rotates the existing oauth_grant row.""" + service, dao = _service() + project_id, user_id = uuid4(), uuid4() + + start1 = await service.begin( + project_id=project_id, user_id=user_id, server_url=_SERVER_URL, scopes=["read"] + ) + completion1 = await service.complete(code="auth-code-1", state=start1.state) + + start2 = await service.begin( + project_id=project_id, + user_id=user_id, + server_url=_SERVER_URL, + scopes=["read", "write"], + ) + completion2 = await service.complete(code="auth-code-2", state=start2.state) + + grant_rows = [r for _, r in dao.records if r.kind.value == "oauth_grant"] + assert len(grant_rows) == 1 + first_id = completion1.secret_id + second_id = completion2.secret_id + assert first_id == second_id diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_state.py b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_state.py new file mode 100644 index 0000000000..49b9b5f123 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_state.py @@ -0,0 +1,86 @@ +"""Unit tests for the MCP OAuth state token (specs-wp17.md "The state token").""" + +from __future__ import annotations + +import time +from uuid import uuid4 + +from oss.src.core.gateways.mcps.oauth.state import decode_state, make_state + +_SECRET = "unit-test-secret" + + +def test_state_round_trips_all_fields(): + project_id, user_id = uuid4(), uuid4() + + state = make_state( + project_id=project_id, + user_id=user_id, + server_url="https://mcp.acme.io/", + code_verifier="a" * 43, + scopes=["read", "write"], + secret_key=_SECRET, + ) + payload = decode_state(state, secret_key=_SECRET) + + assert payload is not None + assert payload["project_id"] == str(project_id) + assert payload["user_id"] == str(user_id) + assert payload["server_url"] == "https://mcp.acme.io/" + assert payload["code_verifier"] == "a" * 43 + assert payload["scopes"] == ["read", "write"] + + +def test_tampered_state_is_rejected(): + state = make_state( + project_id=uuid4(), + user_id=uuid4(), + server_url="https://mcp.acme.io/", + code_verifier="a" * 43, + scopes=[], + secret_key=_SECRET, + ) + tampered = state[:-1] + ("0" if state[-1] != "0" else "1") + + assert decode_state(tampered, secret_key=_SECRET) is None + + +def test_expired_state_is_rejected(): + state = make_state( + project_id=uuid4(), + user_id=uuid4(), + server_url="https://mcp.acme.io/", + code_verifier="a" * 43, + scopes=[], + secret_key=_SECRET, + ) + + assert decode_state(state, secret_key=_SECRET, max_age=-1) is None + + +def test_wrong_secret_key_is_rejected(): + state = make_state( + project_id=uuid4(), + user_id=uuid4(), + server_url="https://mcp.acme.io/", + code_verifier="a" * 43, + scopes=[], + secret_key=_SECRET, + ) + + assert decode_state(state, secret_key="a-different-secret") is None + + +def test_state_carries_a_fresh_timestamp(): + state = make_state( + project_id=uuid4(), + user_id=uuid4(), + server_url="https://mcp.acme.io/", + code_verifier="a" * 43, + scopes=[], + secret_key=_SECRET, + ) + payload = decode_state(state, secret_key=_SECRET) + + assert payload is not None + assert abs(time.time() - payload["ts"]) < 5 diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_storage.py b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_storage.py new file mode 100644 index 0000000000..82ef4aa271 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_oauth_storage.py @@ -0,0 +1,259 @@ +"""Unit tests for `SecretsTokenStorage` (specs-wp17.md "The storage adapter"). + +A real `VaultService` over an in-memory fake `SecretsDAOInterface` — no Postgres, no +encryption key, no network — matching `unit/secrets/test_services.py`'s own fake-DAO +pattern. +""" + +from __future__ import annotations + +from typing import List, Optional, Tuple +from uuid import UUID, uuid4 + +import pytest +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + +from oss.src.core.gateways.mcps.oauth.storage import SecretsTokenStorage +from oss.src.core.secrets.dtos import SecretResponseDTO +from oss.src.core.secrets.services import VaultService + + +class _FakeSecretsDAO: + def __init__(self) -> None: + self.records: List[Tuple[UUID, SecretResponseDTO]] = [] + self.create_calls = 0 + self.update_calls = 0 + + def _scoped(self, project_id) -> List[SecretResponseDTO]: + return [r for p, r in self.records if p == project_id] + + async def create(self, *, project_id=None, organization_id=None, create_secret_dto): + self.create_calls += 1 + record = SecretResponseDTO( + id=uuid4(), + slug=create_secret_dto.slug, + kind=create_secret_dto.secret.kind, + data=create_secret_dto.secret.data.model_dump(exclude_none=True), + header=create_secret_dto.header, + ) + self.records.append((project_id, record)) + return record + + async def get_by_id(self, secret_id, project_id=None, organization_id=None): + return next((r for r in self._scoped(project_id) if r.id == secret_id), None) + + async def get_by_slug(self, secret_slug, project_id=None, organization_id=None): + return next( + (r for r in self._scoped(project_id) if r.slug == secret_slug), None + ) + + async def list(self, project_id=None, organization_id=None): + return self._scoped(project_id) + + async def update( + self, + secret_id, + update_secret_dto, + project_id=None, + organization_id=None, + user_id=None, + ): + self.update_calls += 1 + scoped = self._scoped(project_id) + stored = next((r for r in scoped if r.id == secret_id), None) + if stored is None: + return None + record = SecretResponseDTO( + id=stored.id, + slug=stored.slug, + kind=stored.kind, + data=update_secret_dto.secret.data.model_dump(exclude_none=True), + header=update_secret_dto.header or stored.header, + ) + idx = self.records.index((project_id, stored)) + self.records[idx] = (project_id, record) + return record + + async def delete(self, secret_id, project_id=None, organization_id=None): + self.records = [ + (p, r) + for p, r in self.records + if not (p == project_id and r.id == secret_id) + ] + + +def _storage( + *, + dao: Optional[_FakeSecretsDAO] = None, + project_id=None, + server_url="https://mcp.acme.io/", + authorization_server=None, +): + dao = dao or _FakeSecretsDAO() + vault = VaultService(secrets_dao=dao) + storage = SecretsTokenStorage( + vault_service=vault, + project_id=project_id or uuid4(), + server_url=server_url, + authorization_server=authorization_server, + ) + return storage, dao + + +# --- tokens ------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_get_tokens_returns_none_when_nothing_stored(): + storage, _dao = _storage() + + assert await storage.get_tokens() is None + + +@pytest.mark.asyncio +async def test_set_then_get_tokens_round_trips(): + storage, _dao = _storage() + + await storage.set_tokens( + OAuthToken( + access_token="tok-1", + refresh_token="ref-1", + expires_in=3600, + scope="read write", + ) + ) + tokens = await storage.get_tokens() + + assert tokens is not None + assert tokens.access_token == "tok-1" + assert tokens.refresh_token == "ref-1" + assert tokens.token_type == "Bearer" + assert tokens.scope == "read write" + assert tokens.expires_in is not None and tokens.expires_in > 0 + + +@pytest.mark.asyncio +async def test_second_set_tokens_updates_in_place_not_a_second_row(): + storage, dao = _storage() + + await storage.set_tokens(OAuthToken(access_token="tok-1")) + await storage.set_tokens(OAuthToken(access_token="tok-2")) + + assert dao.create_calls == 1 + assert dao.update_calls == 1 + tokens = await storage.get_tokens() + assert tokens is not None + assert tokens.access_token == "tok-2" + + +@pytest.mark.asyncio +async def test_two_server_urls_under_one_project_do_not_collide(): + dao = _FakeSecretsDAO() + project_id = uuid4() + storage_a, _ = _storage( + dao=dao, project_id=project_id, server_url="https://a.example/mcp" + ) + storage_b, _ = _storage( + dao=dao, project_id=project_id, server_url="https://b.example/mcp" + ) + + await storage_a.set_tokens(OAuthToken(access_token="tok-a")) + await storage_b.set_tokens(OAuthToken(access_token="tok-b")) + + tokens_a = await storage_a.get_tokens() + tokens_b = await storage_b.get_tokens() + assert tokens_a is not None and tokens_a.access_token == "tok-a" + assert tokens_b is not None and tokens_b.access_token == "tok-b" + + +@pytest.mark.asyncio +async def test_two_projects_on_the_same_server_url_do_not_collide(): + dao = _FakeSecretsDAO() + storage_p1, _ = _storage( + dao=dao, project_id=uuid4(), server_url="https://mcp.acme.io/" + ) + storage_p2, _ = _storage( + dao=dao, project_id=uuid4(), server_url="https://mcp.acme.io/" + ) + + await storage_p1.set_tokens(OAuthToken(access_token="tok-p1")) + + assert await storage_p2.get_tokens() is None + tokens_p1 = await storage_p1.get_tokens() + assert tokens_p1 is not None and tokens_p1.access_token == "tok-p1" + + +@pytest.mark.asyncio +async def test_write_tokens_returns_the_written_secret_id(): + storage, _dao = _storage() + + written = await storage.write_tokens(OAuthToken(access_token="tok-1")) + + assert written.id is not None + grant = await storage.get_tokens() + assert grant is not None and grant.access_token == "tok-1" + + +# --- client info --------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_get_client_info_returns_none_when_nothing_stored(): + storage, _dao = _storage() + + assert await storage.get_client_info() is None + + +@pytest.mark.asyncio +async def test_set_then_get_client_info_round_trips(): + storage, _dao = _storage(authorization_server="https://auth.acme.io/") + + info = OAuthClientInformationFull( + redirect_uris=["https://api.agenta.ai/gateways/mcps/connect/callback"], + client_id="client-123", + client_secret="shh", + scope="read write", + ) + await storage.set_client_info(info) + fetched = await storage.get_client_info() + + assert fetched is not None + assert fetched.client_id == "client-123" + assert fetched.client_secret == "shh" + + +@pytest.mark.asyncio +async def test_second_set_client_info_updates_in_place(): + storage, dao = _storage(authorization_server="https://auth.acme.io/") + + await storage.set_client_info( + OAuthClientInformationFull( + redirect_uris=["https://api.agenta.ai/gateways/mcps/connect/callback"], + client_id="client-1", + ) + ) + await storage.set_client_info( + OAuthClientInformationFull( + redirect_uris=["https://api.agenta.ai/gateways/mcps/connect/callback"], + client_id="client-2", + ) + ) + + assert dao.create_calls == 1 + assert dao.update_calls == 1 + fetched = await storage.get_client_info() + assert fetched is not None and fetched.client_id == "client-2" + + +@pytest.mark.asyncio +async def test_client_info_falls_back_to_server_url_before_issuer_is_known(): + storage, _dao = _storage(authorization_server=None) + + info = OAuthClientInformationFull( + redirect_uris=["https://api.agenta.ai/gateways/mcps/connect/callback"], + client_id="client-preregistration", + ) + await storage.set_client_info(info) + fetched = await storage.get_client_info() + + assert fetched is not None and fetched.client_id == "client-preregistration" diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_proxy.py b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_proxy.py new file mode 100644 index 0000000000..513edb89c5 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_proxy.py @@ -0,0 +1,371 @@ +"""Unit tests for MCPGatewayProxy routing (entities.md §9, workstreams/specs-wp8.md). + +In-process ASGI against a mock `MCPGatewayService` and a mockd `get_auth_scope()` — no +Postgres, no real service, no network. Asserts which handler each path reaches (the +each builtin provider's own grammar under its segment), the 405s on the +stream verbs, and the exception -> protocol-error mapping this proxy owns +(`proxy.py::_map_gateway_exception`) — deliberately NOT the seed's +`handle_gateway_exceptions()`, which raises a plain `HTTPException(status, detail=str)` +and would collapse every cause below into an indistinguishable message. Each mapped-cause +test asserts both the HTTP status AND the stable `cause` string in the JSON-RPC error's +`data` — asserting the status alone would also pass under the old, wrong behaviour. +""" + +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from oss.src.apis.fastapi.gateways.mcps.proxy import MCPGatewayProxy +from oss.src.core.access.permissions.types import Permission +from oss.src.core.gateways.dtos import ( + GatewayConnectionRequirement, + GatewayConnectionState, + GatewayEndpointNamespace, +) +from oss.src.core.gateways.mcps.interfaces import MCPRelayResult +from oss.src.core.gateways.mcps.types import ( + MCPAuthRequiredError, + MCPEndpointNotFoundError, + MCPScopeInsufficientError, + MCPToolNotAllowedError, + MCPUpstreamError, +) +from oss.src.core.gateways.policy.dtos import SecretMode, SecretOwnerKind +from oss.src.core.gateways.policy.types import ( + CeilingExceededError, + SecretInvalidError, + SecretNotFoundError, + EntitlementDeniedError, + PolicyDeniedError, +) +from oss.src.utils.context import AuthScope + + +class _MockMcpGatewayService: + def __init__(self): + self.calls = [] + self.raise_error = None + + async def relay(self, **kwargs): + self.calls.append(kwargs) + if self.raise_error is not None: + raise self.raise_error + return MCPRelayResult( + status_code=200, + headers={"content-type": "application/json"}, + body=b'{"jsonrpc": "2.0", "id": 1, "result": {}}', + ) + + +@pytest.fixture +def mock_service(): + return _MockMcpGatewayService() + + +@pytest.fixture +def client(mock_service, monkeypatch): + scope = AuthScope( + organization_id=uuid4(), + workspace_id=uuid4(), + project_id=uuid4(), + user_id=uuid4(), + ) + monkeypatch.setattr( + "oss.src.apis.fastapi.gateways.mcps.proxy.get_auth_scope", + lambda: scope, + ) + + proxy = MCPGatewayProxy(mcp_gateway_service=mock_service) + app = FastAPI() + app.include_router(proxy.router) + return TestClient(app) + + +def test_builtin_agenta_nests_the_slug(client, mock_service): + response = client.post( + "/builtin/agenta/tools/search", + headers={"MCP-Method": "tools/list"}, + content=b"{}", + ) + + assert response.status_code == 200 + call = mock_service.calls[0] + assert call["namespace"] == GatewayEndpointNamespace.BUILTIN + assert call["provider"] == "agenta" + assert call["integration"] is None + assert call["name"] == "tools/search" + + +def test_builtin_reaches_with_three_segments(client, mock_service): + response = client.post( + "/builtin/composio/notion/my-notion", + headers={"MCP-Method": "tools/list"}, + content=b"{}", + ) + + assert response.status_code == 200 + call = mock_service.calls[0] + assert call["namespace"] == GatewayEndpointNamespace.BUILTIN + assert call["provider"] == "composio" + assert call["integration"] == "notion" + assert call["name"] == "my-notion" + + +def test_custom_reaches_with_the_slug(client, mock_service): + response = client.post( + "/custom/acme-notion", + headers={"MCP-Method": "tools/list"}, + content=b"{}", + ) + + assert response.status_code == 200 + call = mock_service.calls[0] + assert call["namespace"] == GatewayEndpointNamespace.CUSTOM + assert call["name"] == "acme-notion" + assert call["provider"] is None + assert call["integration"] is None + + +@pytest.mark.parametrize( + "path", + [ + "/builtin/agenta/tools/search", + "/builtin/composio/notion/my-notion", + "/custom/acme-notion", + ], +) +def test_get_and_delete_return_405(client, path): + assert client.get(path).status_code == 405 + assert client.delete(path).status_code == 405 + + +def test_relayed_body_and_status_pass_through_untouched(client, mock_service): + """The upstream's own protocol-level result (success or its own JSON-RPC `error` + body, D16) never goes through `_map_gateway_exception` — `HttpMCPAdapter` never + raises for it, so it is not this test's concern to mock beyond the happy path; + the mapping only ever sees exceptions from `service.relay` itself.""" + response = client.post( + "/custom/acme-notion", headers={"MCP-Method": "tools/list"}, content=b"{}" + ) + + assert response.status_code == 200 + assert response.content == b'{"jsonrpc": "2.0", "id": 1, "result": {}}' + + +def test_authorization_header_is_not_forwarded_to_the_service(client, mock_service): + client.post( + "/custom/acme-notion", + headers={"MCP-Method": "tools/list", "Authorization": "Secret platform-token"}, + content=b"{}", + ) + + forwarded_headers = mock_service.calls[0]["headers"] + assert "authorization" not in {k.lower() for k in forwarded_headers} + + +# --------------------------------------------------------------------------- +# Gateway-authored refusals -> the proxy's own JSON-RPC error mapping +# (proxy.py::_map_gateway_exception), NOT handle_gateway_exceptions(). Every +# case asserts the HTTP status (unchanged from that decorator's table) AND the +# stable `cause` string in the error body — status alone would also pass under +# the old, wrong HTTPException(detail=str) behaviour. +# --------------------------------------------------------------------------- + + +def _endpoint_not_found(): + return MCPEndpointNotFoundError( + namespace=GatewayEndpointNamespace.CUSTOM, name="missing" + ) + + +def _policy_denied(): + return PolicyDeniedError( + permission=Permission.USE_MCP_ENDPOINTS, target="custom/acme-notion" + ) + + +def _entitlement_denied(): + return EntitlementDeniedError(key="mcp_calls", target="custom/acme-notion") + + +def _tool_not_allowed(): + return MCPToolNotAllowedError( + tool="danger", namespace=GatewayEndpointNamespace.CUSTOM, name="acme-notion" + ) + + +def _ceiling_exceeded(): + return CeilingExceededError( + ceiling="max_calls", requested=10, allowed=5, target="custom/acme-notion" + ) + + +def _auth_required(): + requirement = GatewayConnectionRequirement( + target="custom/acme-notion", state=GatewayConnectionState.NEEDS_AUTH + ) + return MCPAuthRequiredError(requirement=requirement) + + +def _scope_insufficient(): + return MCPScopeInsufficientError(target="custom/acme-notion", scopes=["write"]) + + +def _secret_missing(): + return SecretNotFoundError( + mode=SecretMode.PROJECT_ONLY, + missing=SecretOwnerKind.PROJECT, + target="custom/acme-notion", + ) + + +def _secret_invalid(): + return SecretInvalidError(target="custom/acme-notion") + + +def _upstream_error_below_500(): + return MCPUpstreamError(target="http://upstream", status_code=400) + + +def _upstream_error_5xx(): + return MCPUpstreamError(target="http://upstream", status_code=503) + + +def _upstream_error_no_status(): + return MCPUpstreamError(target="http://upstream") + + +@pytest.mark.parametrize( + "make_error, expected_status, expected_cause", + [ + (_endpoint_not_found, 404, "endpoint_not_found"), + (_policy_denied, 403, "policy_denied"), + (_entitlement_denied, 403, "entitlement_denied"), + (_tool_not_allowed, 403, "tool_not_allowed"), + (_ceiling_exceeded, 400, "ceiling_exceeded"), + (_auth_required, 409, "auth_required"), + (_scope_insufficient, 409, "scope_insufficient"), + (_secret_missing, 409, "secret_missing"), + (_secret_invalid, 409, "secret_invalid"), + (_upstream_error_below_500, 424, "upstream_error"), + (_upstream_error_5xx, 502, "upstream_error"), + (_upstream_error_no_status, 424, "upstream_error"), + ], +) +def test_mapped_gateway_exceptions_carry_status_and_cause( + client, mock_service, make_error, expected_status, expected_cause +): + mock_service.raise_error = make_error() + + response = client.post( + "/custom/acme-notion", headers={"MCP-Method": "tools/list"}, content=b"{}" + ) + + assert response.status_code == expected_status + payload = response.json() + assert payload["jsonrpc"] == "2.0" + assert payload["id"] is None + assert payload["error"]["data"]["cause"] == expected_cause + # never the house `{code,message,retryable,...}` envelope (entities.md §9) + assert "retryable" not in payload["error"]["data"] + + +@pytest.mark.parametrize( + "make_error, expected_status, expected_cause", + [ + (_endpoint_not_found, 404, "endpoint_not_found"), + (_policy_denied, 403, "policy_denied"), + (_entitlement_denied, 403, "entitlement_denied"), + (_tool_not_allowed, 403, "tool_not_allowed"), + (_ceiling_exceeded, 400, "ceiling_exceeded"), + (_auth_required, 409, "auth_required"), + (_scope_insufficient, 409, "scope_insufficient"), + (_secret_missing, 409, "secret_missing"), + (_secret_invalid, 409, "secret_invalid"), + (_upstream_error_below_500, 424, "upstream_error"), + (_upstream_error_5xx, 502, "upstream_error"), + (_upstream_error_no_status, 424, "upstream_error"), + ], +) +def test_mapped_gateway_exceptions_carry_the_code_marker_except_upstream_error( + client, mock_service, make_error, expected_status, expected_cause +): + """WP25/OD18: `cause` must survive in `message` alone on both planes, for the same + reason as the LLM plane — Codex's own SDK keeps only `error.message`. `upstream_error` + is excluded — D16 forwards the upstream's own detail untouched.""" + mock_service.raise_error = make_error() + + response = client.post( + "/custom/acme-notion", headers={"MCP-Method": "tools/list"}, content=b"{}" + ) + + message = response.json()["error"]["message"] + marker = f"⟦agenta_code:{expected_cause}⟧" + if expected_cause == "upstream_error": + assert marker not in message + else: + assert message.endswith(marker) + + +def test_scope_insufficient_with_no_endpoint_id_carries_no_connect_affordance( + client, mock_service +): + """The pre-WP19 shape (WP17's own seed test constructs it this way) stays valid: + `connect` is additive, not required.""" + mock_service.raise_error = _scope_insufficient() + + response = client.post( + "/custom/acme-notion", headers={"MCP-Method": "tools/list"}, content=b"{}" + ) + + data = response.json()["error"]["data"] + assert data["scopes"] == ["write"] + assert "connect" not in data + + +def test_scope_insufficient_with_endpoint_id_carries_the_connect_affordance( + client, mock_service +): + """WP19: the step-up interaction reuses the missing-connection path (D17) — the + same `POST /endpoints/{id}/connect` route WP18 built, re-run with a wider scope + choice. `body: {}` points at step 1 (discover), not a guessed scope list — a + marker-only recovery never carries `e.scopes` this far (WP25).""" + endpoint_id = uuid4() + mock_service.raise_error = MCPScopeInsufficientError( + target="custom/acme-notion", scopes=["write"], endpoint_id=endpoint_id + ) + + response = client.post( + "/custom/acme-notion", headers={"MCP-Method": "tools/list"}, content=b"{}" + ) + + data = response.json()["error"]["data"] + assert data["connect"] == { + "endpoint": f"/gateways/mcps/endpoints/{endpoint_id}/connect", + "body": {}, + } + + +def test_auth_required_carries_the_connect_requirement(client, mock_service): + mock_service.raise_error = _auth_required() + + response = client.post( + "/custom/acme-notion", headers={"MCP-Method": "tools/list"}, content=b"{}" + ) + + requirement = response.json()["error"]["data"]["requirement"] + assert requirement["target"] == "custom/acme-notion" + assert requirement["state"] == "needs_auth" + + +def test_missing_mcp_method_header_is_a_protocol_invalid_request(client, mock_service): + response = client.post("/custom/acme-notion", content=b"{}") + + assert response.status_code == 400 + assert mock_service.calls == [] + payload = response.json() + assert payload["jsonrpc"] == "2.0" + assert payload["id"] is None + assert payload["error"]["data"]["cause"] == "invalid_request" diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_registry.py b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_registry.py new file mode 100644 index 0000000000..f9ef447d2d --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_registry.py @@ -0,0 +1,58 @@ +"""Unit tests for `MCPUpstreamRegistry` (specs-wp9.md, tasks-wp9.md). + +Shape mirrors `ConnectionsGatewayRegistry`: two mock adapters registered, `get()` returns +the right one and raises on a miss, `keys()` lists exactly the registered set. +""" + +import pytest + +from oss.src.core.gateways.mcps.dtos import ( + MCPCallContext, + MCPRelayAuth, + MCPResolvedRoute, +) +from oss.src.core.gateways.mcps.interfaces import MCPRelayResult, MCPUpstreamInterface +from oss.src.core.gateways.mcps.registry import MCPUpstreamRegistry +from oss.src.core.gateways.mcps.types import MCPUpstreamError + + +class _MockAdapter(MCPUpstreamInterface): + def __init__(self, name: str) -> None: + self.name = name + + async def relay( + self, + *, + route: MCPResolvedRoute, + auth: MCPRelayAuth, + context: MCPCallContext, + body: bytes, + headers: dict, + ) -> MCPRelayResult: + return MCPRelayResult(status_code=200, headers={}, body=self.name.encode()) + + +def test_get_returns_the_registered_adapter(): + mock = _MockAdapter("mock") + http = _MockAdapter("http") + registry = MCPUpstreamRegistry(adapters={"mock": mock, "http": http}) + + assert registry.get("mock") is mock + assert registry.get("http") is http + + +def test_get_on_missing_key_raises_mcp_upstream_error(): + registry = MCPUpstreamRegistry(adapters={"mock": _MockAdapter("mock")}) + + with pytest.raises(MCPUpstreamError) as excinfo: + registry.get("composio") + + assert excinfo.value.target == "composio" + + +def test_keys_returns_exactly_the_registered_set(): + registry = MCPUpstreamRegistry( + adapters={"mock": _MockAdapter("mock"), "http": _MockAdapter("http")} + ) + + assert set(registry.keys()) == {"mock", "http"} diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_router.py b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_router.py new file mode 100644 index 0000000000..0ddb27f578 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_router.py @@ -0,0 +1,578 @@ +"""Router wiring — apis/fastapi/gateways/mcps/router.py (entities.md §9). + +TestClient + a hand-written mock `MCPGatewayService` + a monkeypatched +`get_auth_scope()`/`check_action_access()` — no real database, no real service. +""" + +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from oss.src.apis.fastapi.gateways.mcps.router import MCPGatewayRouter +from oss.src.core.gateways.mcps.dtos import MCPAuthScheme +from oss.src.core.gateways.mcps.dtos import ( + MCPEndpoint, + MCPEndpointData, + MCPEndpointRoute, +) +from oss.src.core.gateways.mcps.oauth.dtos import ( + MCPOAuthAuthorizationStart, + MCPOAuthCompletion, + MCPOAuthDiscovery, +) +from oss.src.core.gateways.mcps.oauth.state import make_state +from oss.src.core.gateways.mcps.oauth.types import MCPOAuthDiscoveryError +from oss.src.utils.context import AuthScope +from oss.src.utils.env import env + + +FIXED_SCOPE = AuthScope( + organization_id=uuid4(), + workspace_id=uuid4(), + project_id=uuid4(), + user_id=uuid4(), +) + +EXPECTED_ROUTES = { + ("/endpoints/", "POST"): "create_mcp_endpoint", + ("/endpoints/", "GET"): "list_mcp_endpoints", + ("/endpoints/query", "POST"): "query_mcp_endpoints", + ("/endpoints/{endpoint_id}", "GET"): "fetch_mcp_endpoint", + ("/endpoints/{endpoint_id}", "PUT"): "edit_mcp_endpoint", + ("/endpoints/{endpoint_id}", "DELETE"): "delete_mcp_endpoint", + ("/endpoints/{endpoint_id}/connect", "POST"): "connect_mcp_endpoint", + ("/connect/callback", "GET"): "mcp_connect_callback", +} + +_SERVER_URL = "https://mcp.acme.example/notion" + +# Fixed (not `uuid4()`-at-collection-time) so pytest-xdist workers agree on +# the parametrize IDs — a random id per worker process fails collection. +_A_FIXED_ID = "00000000-0000-0000-0000-000000000001" + + +def _endpoint(endpoint_id) -> MCPEndpoint: + return MCPEndpoint( + id=endpoint_id, + slug="acme-notion", + auth_mode=MCPAuthScheme.NONE, + data=MCPEndpointData( + route=MCPEndpointRoute(base_url="https://mcp.acme.example/notion") + ), + ) + + +def _oauth_endpoint(endpoint_id, *, secret_id=None) -> MCPEndpoint: + return MCPEndpoint( + id=endpoint_id, + slug="acme-notion", + auth_mode=MCPAuthScheme.OAUTH, + secret_id=secret_id, + data=MCPEndpointData(route=MCPEndpointRoute(base_url=_SERVER_URL)), + ) + + +class MockMCPGatewayService: + def __init__(self): + self.calls = [] + self.create_return = None + self.list_return = [] + self.query_return = [] + self.fetch_return = None + self.edit_return = None + self.delete_return = True + + async def create_endpoint(self, *, project_id, user_id, endpoint): + self.calls.append("create_endpoint") + return self.create_return + + async def list_endpoints(self, *, scope): + self.calls.append("list_endpoints") + return self.list_return + + async def query_endpoints(self, *, project_id, endpoint=None, windowing=None): + self.calls.append("query_endpoints") + return self.query_return + + async def fetch_endpoint(self, *, project_id, endpoint_id): + self.calls.append("fetch_endpoint") + return self.fetch_return + + async def edit_endpoint(self, *, project_id, user_id, endpoint): + self.calls.append("edit_endpoint") + return self.edit_return + + async def delete_endpoint(self, *, project_id, endpoint_id): + self.calls.append("delete_endpoint") + return self.delete_return + + +class MockMCPOAuthConnectService: + def __init__(self): + self.calls = [] + self.discover_return = MCPOAuthDiscovery( + resource=_SERVER_URL, + authorization_server="https://auth.acme.example/", + scopes_offered=["read", "write"], + authorization_endpoint="https://auth.acme.example/authorize", + token_endpoint="https://auth.acme.example/token", + ) + self.discover_raises = None + self.begin_return = MCPOAuthAuthorizationStart( + authorization_url="https://auth.acme.example/authorize?client_id=abc", + state="signed-state", + ) + self.complete_return = None + + async def discover(self, *, server_url): + self.calls.append(("discover", server_url)) + if self.discover_raises: + raise self.discover_raises + return self.discover_return + + async def begin(self, *, project_id, user_id, server_url, scopes): + self.calls.append(("begin", server_url, tuple(scopes))) + return self.begin_return + + async def complete(self, *, code, state): + self.calls.append(("complete", code, state)) + if self.complete_return is None: + raise AssertionError("complete_return not set") + return self.complete_return + + +@pytest.fixture +def service(): + return MockMCPGatewayService() + + +@pytest.fixture +def oauth_service(): + return MockMCPOAuthConnectService() + + +@pytest.fixture +def router(service, oauth_service): + return MCPGatewayRouter( + mcp_gateway_service=service, oauth_connect_service=oauth_service + ) + + +@pytest.fixture +def client(router): + app = FastAPI() + app.include_router(router.router) + return TestClient(app, raise_server_exceptions=False) + + +@pytest.fixture(autouse=True) +def _patch_auth_scope(monkeypatch): + monkeypatch.setattr( + "oss.src.apis.fastapi.gateways.mcps.router.get_auth_scope", + lambda: FIXED_SCOPE, + ) + + +@pytest.fixture +def allow(monkeypatch): + mock = AsyncMock(return_value=True) + monkeypatch.setattr( + "oss.src.apis.fastapi.gateways.mcps.router.check_action_access", mock + ) + return mock + + +@pytest.fixture +def deny(monkeypatch): + mock = AsyncMock(return_value=False) + monkeypatch.setattr( + "oss.src.apis.fastapi.gateways.mcps.router.check_action_access", mock + ) + return mock + + +# --------------------------------------------------------------------------- +# Route table — path, method and operation_id match entities.md §9 exactly +# --------------------------------------------------------------------------- + + +def test_route_table_matches_the_design_exactly(router): + actual = {} + for route in router.router.routes: + for method in route.methods: + if method == "HEAD": + continue + actual[(route.path, method)] = route.operation_id + + assert actual == EXPECTED_ROUTES + + +# --------------------------------------------------------------------------- +# POST /endpoints/{id}/connect — the two-step consent flow (specs-wp18.md) +# --------------------------------------------------------------------------- + + +def test_connect_discover_step_caches_scopes_and_returns_the_checklist( + client, service, oauth_service, allow +): + endpoint_id = uuid4() + service.fetch_return = _oauth_endpoint(endpoint_id) + + response = client.post(f"/endpoints/{endpoint_id}/connect", json={}) + + assert response.status_code == 200 + body = response.json() + assert body.get("redirect_url") is None + assert body["scopes_offered"] == ["read", "write"] + assert oauth_service.calls == [("discover", _SERVER_URL)] + assert service.calls == ["fetch_endpoint", "edit_endpoint"] + + +def test_connect_begin_step_returns_the_redirect_url( + client, service, oauth_service, allow +): + endpoint_id = uuid4() + service.fetch_return = _oauth_endpoint(endpoint_id) + + response = client.post( + f"/endpoints/{endpoint_id}/connect", json={"scopes": ["read"]} + ) + + assert response.status_code == 200 + body = response.json() + assert body["redirect_url"] == oauth_service.begin_return.authorization_url + assert oauth_service.calls == [("begin", _SERVER_URL, ("read",))] + # No discovery-caching edit_endpoint call on the begin step. + assert service.calls == ["fetch_endpoint"] + + +def test_connect_missing_endpoint_404s(client, service, oauth_service, allow): + service.fetch_return = None + + response = client.post(f"/endpoints/{_A_FIXED_ID}/connect", json={}) + + assert response.status_code == 404 + assert oauth_service.calls == [] + + +def test_connect_rejects_a_non_oauth_endpoint(client, service, oauth_service, allow): + endpoint_id = uuid4() + service.fetch_return = _endpoint(endpoint_id) # auth_mode=NONE + + response = client.post(f"/endpoints/{endpoint_id}/connect", json={}) + + assert response.status_code == 400 + assert oauth_service.calls == [] + + +def test_connect_denied_check_short_circuits_before_the_service_is_called( + client, service, oauth_service, deny +): + response = client.post(f"/endpoints/{_A_FIXED_ID}/connect", json={}) + + assert response.status_code == 403 + assert service.calls == [] + assert oauth_service.calls == [] + + +def test_connect_discovery_failure_surfaces_its_own_message( + client, service, oauth_service, allow +): + """OD21-inherited constraint: a discovery failure must say what happened, + not a generic connect error (specs-wp18.md).""" + endpoint_id = uuid4() + service.fetch_return = _oauth_endpoint(endpoint_id) + oauth_service.discover_raises = MCPOAuthDiscoveryError( + server_url=_SERVER_URL, detail="no protected-resource metadata found" + ) + + response = client.post(f"/endpoints/{endpoint_id}/connect", json={}) + + assert response.status_code == 424 + assert _SERVER_URL in response.json()["detail"] + assert "no protected-resource metadata found" in response.json()["detail"] + + +# --------------------------------------------------------------------------- +# GET /connect/callback — unauthenticated, driven entirely by `state` +# --------------------------------------------------------------------------- + + +def _signed_state(*, project_id, user_id, scopes=None): + return make_state( + project_id=project_id, + user_id=user_id, + server_url=_SERVER_URL, + code_verifier="a" * 43, + scopes=scopes or ["read"], + secret_key=env.agenta.crypt_key, + ) + + +def test_callback_completes_and_puts_the_secret_id_onto_the_matching_endpoint( + client, service, oauth_service +): + endpoint_id = uuid4() + project_id, user_id = uuid4(), uuid4() + secret_id = uuid4() + service.query_return = [_oauth_endpoint(endpoint_id)] + oauth_service.complete_return = MCPOAuthCompletion( + project_id=project_id, server_url=_SERVER_URL, secret_id=secret_id + ) + state = _signed_state(project_id=project_id, user_id=user_id) + + response = client.get( + "/connect/callback", params={"code": "auth-code", "state": state} + ) + + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "mcp:oauth:connected" in response.text + assert service.calls == ["query_endpoints", "edit_endpoint"] + assert oauth_service.calls == [("complete", "auth-code", state)] + + +def test_callback_with_no_matching_endpoint_renders_a_failure_card( + client, service, oauth_service +): + project_id, user_id = uuid4(), uuid4() + service.query_return = [] # nothing registered for this server_url + oauth_service.complete_return = MCPOAuthCompletion( + project_id=project_id, server_url=_SERVER_URL, secret_id=uuid4() + ) + state = _signed_state(project_id=project_id, user_id=user_id) + + response = client.get( + "/connect/callback", params={"code": "auth-code", "state": state} + ) + + assert response.status_code == 400 + assert "No matching MCP endpoint" in response.text + assert service.calls == ["query_endpoints"] # no edit_endpoint call + + +def test_callback_with_authorization_server_error_renders_a_failure_card_without_completing( + client, service, oauth_service +): + response = client.get( + "/connect/callback", + params={"error": "access_denied", "error_description": "User declined"}, + ) + + assert response.status_code == 400 + assert "User declined" in response.text + assert oauth_service.calls == [] + assert service.calls == [] + + +def test_callback_with_a_tampered_state_renders_a_failure_card( + client, service, oauth_service +): + project_id, user_id = uuid4(), uuid4() + state = _signed_state(project_id=project_id, user_id=user_id) + tampered = state[:-1] + ("0" if state[-1] != "0" else "1") + + response = client.get( + "/connect/callback", params={"code": "auth-code", "state": tampered} + ) + + assert response.status_code == 400 + assert "invalid or expired" in response.text.lower() + assert oauth_service.calls == [] + assert service.calls == [] + + +# --------------------------------------------------------------------------- +# Each route reaches the right handler (happy path) +# --------------------------------------------------------------------------- + + +def test_create_endpoint_reaches_the_service(client, service, allow): + endpoint_id = uuid4() + service.create_return = _endpoint(endpoint_id) + + response = client.post( + "/endpoints/", + json={ + "endpoint": { + "slug": "acme-notion", + "auth_mode": "none", + "data": {"route": {"base_url": "https://mcp.acme.example/notion"}}, + } + }, + ) + + assert response.status_code == 200 + assert response.json()["count"] == 1 + assert response.json()["endpoint"]["id"] == str(endpoint_id) + assert service.calls == ["create_endpoint"] + + +def test_list_endpoints_reaches_the_service(client, service, allow): + service.list_return = [_endpoint(uuid4())] + + response = client.get("/endpoints/") + + assert response.status_code == 200 + assert response.json()["count"] == 1 + assert service.calls == ["list_endpoints"] + + +def test_query_endpoints_reaches_the_service(client, service, allow): + service.query_return = [_endpoint(uuid4())] + + response = client.post("/endpoints/query", json={}) + + assert response.status_code == 200 + assert response.json()["count"] == 1 + assert service.calls == ["query_endpoints"] + + +def test_fetch_endpoint_reaches_the_service(client, service, allow): + endpoint_id = uuid4() + service.fetch_return = _endpoint(endpoint_id) + + response = client.get(f"/endpoints/{endpoint_id}") + + assert response.status_code == 200 + assert response.json()["endpoint"]["id"] == str(endpoint_id) + assert service.calls == ["fetch_endpoint"] + + +def test_edit_endpoint_reaches_the_service(client, service, allow): + endpoint_id = uuid4() + service.edit_return = _endpoint(endpoint_id) + + response = client.put( + f"/endpoints/{endpoint_id}", + json={ + "endpoint": { + "id": str(endpoint_id), + "auth_mode": "none", + "data": {"route": {"base_url": "https://mcp.acme.example/notion"}}, + } + }, + ) + + assert response.status_code == 200 + assert service.calls == ["edit_endpoint"] + + +def test_edit_endpoint_rejects_a_path_body_id_mismatch(client, service, allow): + endpoint_id = uuid4() + other_id = uuid4() + + response = client.put( + f"/endpoints/{endpoint_id}", + json={ + "endpoint": { + "id": str(other_id), + "auth_mode": "none", + "data": {"route": {"base_url": "https://mcp.acme.example/notion"}}, + } + }, + ) + + assert response.status_code == 400 + assert service.calls == [] + + +def test_delete_endpoint_reaches_the_service(client, service, allow): + endpoint_id = uuid4() + service.delete_return = True + + response = client.delete(f"/endpoints/{endpoint_id}") + + assert response.status_code == 204 + assert service.calls == ["delete_endpoint"] + + +# --------------------------------------------------------------------------- +# A denied _check short-circuits before the mock service is called +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "method,path,json_body", + [ + ( + "POST", + "/endpoints/", + { + "endpoint": { + "auth_mode": "none", + "data": {"route": {"base_url": "https://example.com"}}, + } + }, + ), + ("GET", "/endpoints/", None), + ("POST", "/endpoints/query", {}), + ("GET", f"/endpoints/{_A_FIXED_ID}", None), + ( + "PUT", + f"/endpoints/{_A_FIXED_ID}", + { + "endpoint": { + "auth_mode": "none", + "data": {"route": {"base_url": "https://example.com"}}, + } + }, + ), + ("DELETE", f"/endpoints/{_A_FIXED_ID}", None), + ], +) +def test_denied_check_short_circuits_before_the_service_is_called( + client, service, deny, method, path, json_body +): + response = client.request(method, path, json=json_body) + + assert response.status_code == 403 + assert service.calls == [] + + +# --------------------------------------------------------------------------- +# None/False from the service maps to 404 +# --------------------------------------------------------------------------- + + +def test_fetch_endpoint_none_maps_to_404(client, service, allow): + service.fetch_return = None + + response = client.get(f"/endpoints/{_A_FIXED_ID}") + + assert response.status_code == 404 + + +def test_edit_endpoint_none_maps_to_404(client, service, allow): + service.edit_return = None + + response = client.put( + f"/endpoints/{_A_FIXED_ID}", + json={ + "endpoint": { + "id": _A_FIXED_ID, + "auth_mode": "none", + "data": {"route": {"base_url": "https://mcp.acme.example/notion"}}, + } + }, + ) + + assert response.status_code == 404 + + +def test_delete_endpoint_false_maps_to_404(client, service, allow): + service.delete_return = False + + response = client.delete(f"/endpoints/{_A_FIXED_ID}") + + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# Wave-1: grant methods are declared-but-NotImplementedError; the mapping +# table does not catch it, so it propagates as an unhandled 500. Expected, +# not a bug to fix here (specs-wp10.md, tasks-wp10.md). +# --------------------------------------------------------------------------- diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_service.py b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_service.py new file mode 100644 index 0000000000..f88914e40f --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_service.py @@ -0,0 +1,931 @@ +"""Unit tests for `MCPGatewayService` (specs-wp9.md, tasks-wp9.md). + +Every case runs against in-memory mocks — a dict-backed `MCPEndpointsDAOInterface`, a +a stub `ConnectionsService`, a call-logging +`GatewayPolicyService`, and a call-logging `SecretsResolverInterface`. No Postgres, no +HTTP, no real upstream. Organized by the commit sections in tasks-wp9.md: +CRUD delegation, the three-namespace merge, connection-state derivation, the declared +grants surface, and the relay six-step orchestration. +""" + +from typing import Dict, List, Optional +from uuid import UUID, uuid4 + +import pytest + +import json + +from oss.src.core.gateway.connections.dtos import Connection, ConnectionProviderKind +from oss.src.core.gateways.mcps.dtos import ( + MCPBrokeredAuth, + MCPCallContext, + MCPDirectAuth, + MCPEndpoint, + MCPEndpointCreate, + MCPEndpointData, + MCPEndpointEdit, + MCPEndpointFlags, + MCPEndpointQuery, + MCPEndpointRoute, + MCPToolFilter, +) +from oss.src.core.gateways.mcps.interfaces import ( + MCPEndpointsDAOInterface, + MCPRelayResult, +) +from oss.src.core.gateways.mcps.registry import MCPUpstreamRegistry +from oss.src.core.gateways.mcps.service import MCPGatewayService +from oss.src.core.gateways.mcps.types import ( + MCPEndpointNotFoundError, + MCPScopeInsufficientError, + MCPToolNotAllowedError, + MCPUpstreamError, +) +from oss.src.core.gateways.policy.dtos import ( + SecretOwner, + SecretOwnerKind, + PolicyDecision, + ResolvedSecret, + SecretOrigin, +) +from oss.src.core.gateways.policy.service import GatewayPolicyService +from oss.src.core.gateways.policy.types import PolicyDeniedError +from oss.src.core.gateways.dtos import GatewayConnectionState +from oss.src.core.gateways.mcps.dtos import MCPAuthScheme +from oss.src.core.secrets.dtos import ( + CustomSecretDTO, + CustomSecretSettingsDTO, + SecretResponseDTO, +) +from oss.src.core.secrets.enums import CustomSecretFormat, SecretKind +from oss.src.core.shared.dtos import Header +from oss.src.utils.context import AuthScope +from oss.src.utils.env import env + + +# --- mocks (this package must not subclass the real Postgres DAO or ConnectionsService) --- # + + +class MockMCPEndpointsDAO(MCPEndpointsDAOInterface): + """In-memory endpoint_id -> MCPEndpoint map. Records every call for assertion.""" + + def __init__(self) -> None: + self._by_id: Dict[UUID, MCPEndpoint] = {} + self.calls: List[str] = [] + + async def create_endpoint( + self, *, project_id, user_id, endpoint + ) -> Optional[MCPEndpoint]: + self.calls.append("create_endpoint") + created = MCPEndpoint(id=uuid4(), **endpoint.model_dump()) + self._by_id[created.id] = created + return created + + async def fetch_endpoint(self, *, project_id, endpoint_id) -> Optional[MCPEndpoint]: + self.calls.append("fetch_endpoint") + return self._by_id.get(endpoint_id) + + async def fetch_endpoint_by_slug( + self, *, project_id, slug + ) -> Optional[MCPEndpoint]: + self.calls.append("fetch_endpoint_by_slug") + return next((e for e in self._by_id.values() if e.slug == slug), None) + + async def edit_endpoint( + self, *, project_id, user_id, endpoint + ) -> Optional[MCPEndpoint]: + self.calls.append("edit_endpoint") + existing = self._by_id.get(endpoint.id) + if existing is None: + return None + updated = existing.model_copy( + update={ + "auth_mode": endpoint.auth_mode, + "secret_id": endpoint.secret_id, + "data": endpoint.data, + "flags": endpoint.flags, + } + ) + self._by_id[endpoint.id] = updated + return updated + + async def delete_endpoint(self, *, project_id, endpoint_id) -> bool: + self.calls.append("delete_endpoint") + return self._by_id.pop(endpoint_id, None) is not None + + async def query_endpoints( + self, *, project_id, endpoint=None, windowing=None + ) -> List[MCPEndpoint]: + self.calls.append("query_endpoints") + return list(self._by_id.values()) + + +class MockConnectionsService: + """In-memory stand-in for `ConnectionsService`; only `query_connections` and + `get_connection` are called by `MCPGatewayService`.""" + + def __init__(self, connections: Optional[List[Connection]] = None) -> None: + self._connections = connections or [] + self.query_connections_calls: List[dict] = [] + + async def query_connections( + self, *, project_id, provider_key=None, integration_key=None, is_active=True + ): + self.query_connections_calls.append( + {"provider_key": provider_key, "integration_key": integration_key} + ) + return [ + c + for c in self._connections + if (provider_key is None or c.provider_key.value == provider_key) + and (integration_key is None or c.integration_key == integration_key) + ] + + async def get_connection(self, *, project_id, connection_id): + return next((c for c in self._connections if c.id == connection_id), None) + + +def _connection( + *, + slug: str = "my-notion", + integration_key: str = "notion", + is_active: bool = True, + is_valid: bool = True, +) -> Connection: + return Connection( + id=uuid4(), + slug=slug, + name=slug, + provider_key=ConnectionProviderKind.COMPOSIO, + integration_key=integration_key, + flags={"is_active": is_active, "is_valid": is_valid}, + ) + + +def _endpoint_create(slug: str = "acme-notion") -> MCPEndpointCreate: + return MCPEndpointCreate( + slug=slug, + auth_mode=MCPAuthScheme.NONE, + data=MCPEndpointData( + route=MCPEndpointRoute(base_url="https://example.com/mcp") + ), + flags=MCPEndpointFlags(), + ) + + +def _service( + *, + mcp_endpoints_dao=None, + connections_service=None, + resolver=None, +) -> MCPGatewayService: + from unittest.mock import AsyncMock + + return MCPGatewayService( + mcp_endpoints_dao=mcp_endpoints_dao or MockMCPEndpointsDAO(), + policy=GatewayPolicyService(resolver=AsyncMock()), + resolver=resolver if resolver is not None else AsyncMock(), + upstream_registry=MCPUpstreamRegistry(adapters={}), + connections_service=connections_service or MockConnectionsService(), + ) + + +# --- CRUD delegation ------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_create_endpoint_delegates_to_dao(): + dao = MockMCPEndpointsDAO() + service = _service(mcp_endpoints_dao=dao) + project_id, user_id = uuid4(), uuid4() + + created = await service.create_endpoint( + project_id=project_id, user_id=user_id, endpoint=_endpoint_create() + ) + + assert dao.calls == ["create_endpoint"] + assert created is not None + assert created.slug == "acme-notion" + + +@pytest.mark.asyncio +async def test_fetch_endpoint_delegates_to_dao_and_passes_through_result(): + dao = MockMCPEndpointsDAO() + service = _service(mcp_endpoints_dao=dao) + created = await service.create_endpoint( + project_id=uuid4(), user_id=uuid4(), endpoint=_endpoint_create() + ) + dao.calls.clear() + + fetched = await service.fetch_endpoint(project_id=uuid4(), endpoint_id=created.id) + + assert dao.calls == ["fetch_endpoint"] + assert fetched is created + + +@pytest.mark.asyncio +async def test_fetch_endpoint_missing_returns_none(): + service = _service() + + fetched = await service.fetch_endpoint(project_id=uuid4(), endpoint_id=uuid4()) + + assert fetched is None + + +@pytest.mark.asyncio +async def test_edit_endpoint_delegates_to_dao(): + dao = MockMCPEndpointsDAO() + service = _service(mcp_endpoints_dao=dao) + created = await service.create_endpoint( + project_id=uuid4(), user_id=uuid4(), endpoint=_endpoint_create() + ) + dao.calls.clear() + + edit = MCPEndpointEdit( + id=created.id, + auth_mode=MCPAuthScheme.NONE, + data=MCPEndpointData( + route=MCPEndpointRoute(base_url="https://example.com/mcp-v2") + ), + ) + edited = await service.edit_endpoint( + project_id=uuid4(), user_id=uuid4(), endpoint=edit + ) + + assert dao.calls == ["edit_endpoint"] + assert edited.data.route.base_url == "https://example.com/mcp-v2" + + +@pytest.mark.asyncio +async def test_delete_endpoint_delegates_to_dao(): + dao = MockMCPEndpointsDAO() + service = _service(mcp_endpoints_dao=dao) + created = await service.create_endpoint( + project_id=uuid4(), user_id=uuid4(), endpoint=_endpoint_create() + ) + dao.calls.clear() + + deleted = await service.delete_endpoint(project_id=uuid4(), endpoint_id=created.id) + + assert dao.calls == ["delete_endpoint"] + assert deleted is True + + +@pytest.mark.asyncio +async def test_query_endpoints_delegates_to_dao_and_returns_its_rows(): + dao = MockMCPEndpointsDAO() + service = _service(mcp_endpoints_dao=dao) + await service.create_endpoint( + project_id=uuid4(), user_id=uuid4(), endpoint=_endpoint_create("a") + ) + await service.create_endpoint( + project_id=uuid4(), user_id=uuid4(), endpoint=_endpoint_create("b") + ) + dao.calls.clear() + + rows = await service.query_endpoints( + project_id=uuid4(), endpoint=MCPEndpointQuery() + ) + + assert dao.calls == ["query_endpoints"] + assert {r.slug for r in rows} == {"a", "b"} + + +# --- list_endpoints: the three-namespace merge --------------------------------------- # + + +@pytest.mark.asyncio +async def test_list_endpoints_agenta_entry_has_no_id_and_builtin_namespace(): + service = _service() + + endpoints = await service.list_endpoints(scope=_scope()) + + agenta = [e for e in endpoints if e.slug == "tools"] + assert len(agenta) == 1 + assert agenta[0].id is None + assert agenta[0].namespace.value == "builtin" + # The provider segment and the dialable URL are what make the entry reachable — + # both were silently dropped once by a rename, because the DTO ignores extras. + assert agenta[0].provider_key == "agenta" + assert agenta[0].data.route.base_url == env.mock_gateways.mcp_url + + +@pytest.mark.asyncio +async def test_list_endpoints_builtin_entries_stamp_connection_fields(): + connection = _connection(slug="my-notion", integration_key="notion") + service = _service(connections_service=MockConnectionsService([connection])) + + endpoints = await service.list_endpoints(scope=_scope()) + + # both providers land in `builtin` now, so select composio's by its connection + builtin = [e for e in endpoints if e.connection_id is not None] + assert len(builtin) == 1 + assert builtin[0].namespace.value == "builtin" + assert builtin[0].connection_id == connection.id + assert builtin[0].provider_key == "composio" + assert builtin[0].integration_key == "notion" + assert builtin[0].slug == "my-notion" + assert builtin[0].id is None # generated, never a row (D19/D20) + + +@pytest.mark.asyncio +async def test_list_endpoints_builtin_queries_connections_service_for_composio_only(): + connections_service = MockConnectionsService([_connection()]) + service = _service(connections_service=connections_service) + + await service.list_endpoints(scope=_scope()) + + assert connections_service.query_connections_calls == [ + {"provider_key": "composio", "integration_key": None} + ] + + +@pytest.mark.asyncio +async def test_list_endpoints_custom_rows_carry_custom_namespace(): + dao = MockMCPEndpointsDAO() + service = _service(mcp_endpoints_dao=dao) + await service.create_endpoint( + project_id=uuid4(), user_id=uuid4(), endpoint=_endpoint_create("acme-notion") + ) + + endpoints = await service.list_endpoints(scope=_scope()) + + custom = [e for e in endpoints if e.namespace.value == "custom"] + assert len(custom) == 1 + assert custom[0].slug == "acme-notion" + + +@pytest.mark.asyncio +async def test_list_endpoints_never_writes_a_generated_entry_to_the_dao(): + dao = MockMCPEndpointsDAO() + service = _service( + mcp_endpoints_dao=dao, + connections_service=MockConnectionsService([_connection()]), + ) + + await service.list_endpoints(scope=_scope()) + + assert "create_endpoint" not in dao.calls + assert "edit_endpoint" not in dao.calls + assert "delete_endpoint" not in dao.calls + + +# --- connection-state derivation (entities.md §8) ------------------------------------- # + + +@pytest.mark.asyncio +async def test_connection_state_none_scheme_is_ready_unconditionally(): + service = _service() + endpoint = MCPEndpoint( + id=uuid4(), + slug="acme", + auth_mode=MCPAuthScheme.NONE, + namespace="custom", + data=MCPEndpointData(route=MCPEndpointRoute(base_url="https://example.com")), + ) + + state = await service._connection_state( + project_id=uuid4(), user_id=uuid4(), endpoint=endpoint + ) + + assert state == GatewayConnectionState.READY + + +# --- grants: declared, not implemented (WP17/WP18) ------------------------------------ # + + +@pytest.mark.asyncio +async def test_connection_state_builtin_with_valid_connection_is_ready(): + connection = _connection(is_active=True, is_valid=True) + service = _service(connections_service=MockConnectionsService([connection])) + endpoint = MCPEndpoint( + slug=connection.slug, + auth_mode=MCPAuthScheme.OAUTH, + namespace="builtin", + connection_id=connection.id, + data=MCPEndpointData( + route=MCPEndpointRoute(base_url="composio://composio/notion/my-notion") + ), + ) + + state = await service._connection_state( + project_id=uuid4(), user_id=uuid4(), endpoint=endpoint + ) + + assert state == GatewayConnectionState.READY + + +@pytest.mark.asyncio +async def test_connection_state_builtin_with_invalid_connection_needs_auth(): + connection = _connection(is_active=True, is_valid=False) + service = _service(connections_service=MockConnectionsService([connection])) + endpoint = MCPEndpoint( + slug=connection.slug, + auth_mode=MCPAuthScheme.OAUTH, + namespace="builtin", + connection_id=connection.id, + data=MCPEndpointData( + route=MCPEndpointRoute(base_url="composio://composio/notion/my-notion") + ), + ) + + state = await service._connection_state( + project_id=uuid4(), user_id=uuid4(), endpoint=endpoint + ) + + assert state == GatewayConnectionState.NEEDS_AUTH + + +# --- relay: the six-step orchestration ------------------------------------------------- # + + +class MockUpstreamAdapter: + """Logs every call; returns a canned result or raises a canned exception.""" + + def __init__(self, *, result=None, raise_exc=None) -> None: + self.relay_calls = 0 + self.last_auth = None + self._result = result + self._raise = raise_exc + + async def relay(self, *, route, auth, context, body, headers): + self.relay_calls += 1 + self.last_auth = auth + if self._raise is not None: + raise self._raise + return self._result or MCPRelayResult( + status_code=200, + headers={"content-type": "application/json"}, + body=b'{"jsonrpc":"2.0","id":1,"result":{}}', + ) + + +class MockResolver: + """Logs every call; returns a canned secret or raises.""" + + def __init__(self, *, secret=None, raise_exc=None) -> None: + self.resolve_calls = 0 + self.last_mode = None + self._secret = secret + self._raise = raise_exc + + async def resolve(self, *, scope, ref, mode): + self.resolve_calls += 1 + self.last_mode = mode + if self._raise is not None: + raise self._raise + return self._secret + + async def available_provider_keys(self, *, scope): + return set() + + +class MockPolicyService: + """Logs authorize()/record() calls in order; the decision is fixed per test.""" + + def __init__(self, *, allow: bool = True) -> None: + self.allow = allow + self.authorize_calls = 0 + self.record_calls: List[dict] = [] + + async def authorize(self, *, scope, permission, target): + self.authorize_calls += 1 + return PolicyDecision( + allowed=self.allow, + permission=permission, + reason=None if self.allow else "permission_denied", + ) + + async def record(self, *, scope, target, decision, outcome): + self.record_calls.append( + {"target": target, "decision": decision, "outcome": outcome} + ) + + +def _resolved_secret(*, user_id: Optional[UUID] = None) -> ResolvedSecret: + return ResolvedSecret( + secret=SecretResponseDTO( + id=uuid4(), + kind=SecretKind.CUSTOM_SECRET, + data=CustomSecretDTO( + secret=CustomSecretSettingsDTO( + format=CustomSecretFormat.TEXT, content="token" + ) + ), + header=Header(name="grant"), + ), + owner=SecretOwner( + kind=SecretOwnerKind.USER if user_id else SecretOwnerKind.PROJECT, + user_id=user_id, + ), + origin=SecretOrigin.VAULT, + ) + + +def _scope() -> AuthScope: + return AuthScope( + organization_id=uuid4(), + workspace_id=uuid4(), + project_id=uuid4(), + user_id=uuid4(), + ) + + +def _relay_service( + *, + mcp_endpoints_dao=None, + connections_service=None, + resolver=None, + policy=None, + adapters: Dict[str, object], +) -> MCPGatewayService: + return MCPGatewayService( + mcp_endpoints_dao=mcp_endpoints_dao or MockMCPEndpointsDAO(), + policy=policy or MockPolicyService(), + resolver=resolver or MockResolver(), + upstream_registry=MCPUpstreamRegistry(adapters=adapters), + connections_service=connections_service or MockConnectionsService(), + ) + + +@pytest.mark.asyncio +async def test_relay_builtin_agenta_none_scheme_dispatches_without_touching_resolver(): + adapter = MockUpstreamAdapter() + resolver = MockResolver() + policy = MockPolicyService() + service = _relay_service( + resolver=resolver, policy=policy, adapters={"mock": adapter} + ) + + result = await service.relay( + scope=_scope(), + namespace="builtin", + provider="agenta", + name="tools", + context=MCPCallContext(method="tools/call", target="echo"), + body=b"{}", + headers={}, + ) + + assert result.status_code == 200 + assert adapter.relay_calls == 1 + assert resolver.resolve_calls == 0 + assert isinstance(adapter.last_auth, MCPDirectAuth) + assert adapter.last_auth.secret is None + assert len(policy.record_calls) == 1 + assert policy.record_calls[0]["outcome"].status_code == 200 + + +@pytest.mark.asyncio +async def test_relay_custom_not_found_raises(): + service = _relay_service(adapters={"http": MockUpstreamAdapter()}) + + with pytest.raises(MCPEndpointNotFoundError): + await service.relay( + scope=_scope(), + namespace="custom", + name="missing", + context=MCPCallContext(method="tools/call", target="echo"), + body=b"{}", + headers={}, + ) + + +async def _custom_endpoint( + dao: "MockMCPEndpointsDAO", *, tools: Optional[MCPToolFilter] = None +) -> MCPEndpoint: + return await dao.create_endpoint( + project_id=uuid4(), + user_id=uuid4(), + endpoint=MCPEndpointCreate( + slug="acme-notion", + auth_mode=MCPAuthScheme.NONE, + data=MCPEndpointData( + route=MCPEndpointRoute(base_url="https://example.com/mcp"), + tools=tools or MCPToolFilter(), + ), + ), + ) + + +@pytest.mark.asyncio +async def test_relay_tool_outside_include_policy_raises_before_resolver_or_adapter(): + dao = MockMCPEndpointsDAO() + await _custom_endpoint(dao, tools=MCPToolFilter(allowlist=["a"])) + adapter = MockUpstreamAdapter() + resolver = MockResolver() + policy = MockPolicyService() + service = _relay_service( + mcp_endpoints_dao=dao, + resolver=resolver, + policy=policy, + adapters={"http": adapter}, + ) + + with pytest.raises(MCPToolNotAllowedError): + await service.relay( + scope=_scope(), + namespace="custom", + name="acme-notion", + context=MCPCallContext(method="tools/call", target="b"), + body=b"{}", + headers={}, + ) + + assert adapter.relay_calls == 0 + assert resolver.resolve_calls == 0 + assert policy.authorize_calls == 0 + + +@pytest.mark.asyncio +async def test_relay_empty_include_policy_refuses_every_tool(): + dao = MockMCPEndpointsDAO() + await _custom_endpoint(dao, tools=MCPToolFilter(allowlist=[])) + service = _relay_service( + mcp_endpoints_dao=dao, adapters={"http": MockUpstreamAdapter()} + ) + + with pytest.raises(MCPToolNotAllowedError): + await service.relay( + scope=_scope(), + namespace="custom", + name="acme-notion", + context=MCPCallContext(method="tools/call", target="anything"), + body=b"{}", + headers={}, + ) + + +@pytest.mark.asyncio +async def test_relay_policy_denial_records_before_raising(): + dao = MockMCPEndpointsDAO() + await _custom_endpoint(dao) + adapter = MockUpstreamAdapter() + policy = MockPolicyService(allow=False) + service = _relay_service( + mcp_endpoints_dao=dao, policy=policy, adapters={"http": adapter} + ) + + with pytest.raises(PolicyDeniedError): + await service.relay( + scope=_scope(), + namespace="custom", + name="acme-notion", + context=MCPCallContext(method="tools/list"), + body=b"{}", + headers={}, + ) + + assert len(policy.record_calls) == 1 + assert policy.record_calls[0]["outcome"].status_code == 403 + assert adapter.relay_calls == 0 + + +@pytest.mark.asyncio +async def test_relay_builtin_never_touches_resolver_only_connections_service(): + connection = _connection(slug="my-notion", integration_key="notion") + connections_service = MockConnectionsService([connection]) + adapter = MockUpstreamAdapter() + resolver = MockResolver() + service = _relay_service( + connections_service=connections_service, + resolver=resolver, + adapters={"composio": adapter}, + ) + + result = await service.relay( + scope=_scope(), + namespace="builtin", + name="my-notion", + provider="composio", + integration="notion", + context=MCPCallContext(method="initialize"), + body=b"{}", + headers={}, + ) + + assert result.status_code == 200 + assert resolver.resolve_calls == 0 + assert adapter.relay_calls == 1 + assert isinstance(adapter.last_auth, MCPBrokeredAuth) + assert adapter.last_auth.connection is connection + + +@pytest.mark.asyncio +async def test_relay_upstream_failure_records_outcome_before_raising(): + dao = MockMCPEndpointsDAO() + await _custom_endpoint(dao) + adapter = MockUpstreamAdapter( + raise_exc=MCPUpstreamError(target="x", status_code=502) + ) + policy = MockPolicyService() + service = _relay_service( + mcp_endpoints_dao=dao, policy=policy, adapters={"http": adapter} + ) + + with pytest.raises(MCPUpstreamError): + await service.relay( + scope=_scope(), + namespace="custom", + name="acme-notion", + context=MCPCallContext(method="tools/call", target="echo"), + body=b"{}", + headers={}, + ) + + assert len(policy.record_calls) == 1 + assert policy.record_calls[0]["outcome"].status_code == 502 + + +def _tools_list_result(names: List[str]) -> MCPRelayResult: + body = { + "jsonrpc": "2.0", + "id": 1, + "result": { + "tools": [{"name": n, "description": n, "inputSchema": {}} for n in names] + }, + } + return MCPRelayResult(status_code=200, headers={}, body=json.dumps(body).encode()) + + +@pytest.mark.asyncio +async def test_relay_tools_list_filters_by_include_policy(): + dao = MockMCPEndpointsDAO() + await _custom_endpoint(dao, tools=MCPToolFilter(allowlist=["a", "b"])) + adapter = MockUpstreamAdapter(result=_tools_list_result(["a", "b", "c"])) + service = _relay_service(mcp_endpoints_dao=dao, adapters={"http": adapter}) + + result = await service.relay( + scope=_scope(), + namespace="custom", + name="acme-notion", + context=MCPCallContext(method="tools/list"), + body=b"{}", + headers={}, + ) + + payload = json.loads(result.body) + names = {t["name"] for t in payload["result"]["tools"]} + assert names == {"a", "b"} + + +@pytest.mark.asyncio +async def test_relay_tools_list_passes_through_untouched_when_policy_is_all(): + dao = MockMCPEndpointsDAO() + await _custom_endpoint(dao, tools=MCPToolFilter()) + adapter = MockUpstreamAdapter(result=_tools_list_result(["a", "b", "c"])) + service = _relay_service(mcp_endpoints_dao=dao, adapters={"http": adapter}) + + result = await service.relay( + scope=_scope(), + namespace="custom", + name="acme-notion", + context=MCPCallContext(method="tools/list"), + body=b"{}", + headers={}, + ) + + payload = json.loads(result.body) + names = {t["name"] for t in payload["result"]["tools"]} + assert names == {"a", "b", "c"} + + +# --- relay: step-up scope challenge (D17, WP19) ----------------------------------------- # + + +async def _oauth_endpoint(dao: "MockMCPEndpointsDAO") -> MCPEndpoint: + return await dao.create_endpoint( + project_id=uuid4(), + user_id=uuid4(), + endpoint=MCPEndpointCreate( + slug="acme-notion", + auth_mode=MCPAuthScheme.OAUTH, + secret_id=uuid4(), + data=MCPEndpointData( + route=MCPEndpointRoute(base_url="https://example.com/mcp") + ), + ), + ) + + +def _challenge_result(*, www_authenticate: str) -> MCPRelayResult: + return MCPRelayResult( + status_code=403, + headers={"WWW-Authenticate": www_authenticate}, + body=b"", + ) + + +@pytest.mark.asyncio +async def test_relay_scope_challenge_with_scope_param_raises_with_the_requested_scopes(): + dao = MockMCPEndpointsDAO() + endpoint = await _oauth_endpoint(dao) + adapter = MockUpstreamAdapter( + result=_challenge_result( + www_authenticate='Bearer error="insufficient_scope", scope="notion:write"' + ) + ) + policy = MockPolicyService() + service = _relay_service( + mcp_endpoints_dao=dao, + resolver=MockResolver(secret=_resolved_secret()), + policy=policy, + adapters={"http": adapter}, + ) + + with pytest.raises(MCPScopeInsufficientError) as excinfo: + await service.relay( + scope=_scope(), + namespace="custom", + name="acme-notion", + context=MCPCallContext(method="tools/call", target="write_page"), + body=b"{}", + headers={}, + ) + + assert excinfo.value.scopes == ["notion:write"] + assert excinfo.value.endpoint_id == endpoint.id + assert excinfo.value.target == "custom/acme-notion" + # Step-up is an interaction, not a bare failure (D17) — the outcome is still recorded. + assert policy.record_calls[-1]["outcome"].status_code == 403 + + +@pytest.mark.asyncio +async def test_relay_scope_challenge_without_scope_param_raises_with_an_empty_list(): + """No `scope=` on the challenge: the dialog re-discovers the offered set instead of + this refusal guessing which scopes matter (WP18's step 1).""" + dao = MockMCPEndpointsDAO() + await _oauth_endpoint(dao) + adapter = MockUpstreamAdapter( + result=_challenge_result(www_authenticate='Bearer error="insufficient_scope"') + ) + service = _relay_service( + mcp_endpoints_dao=dao, + resolver=MockResolver(secret=_resolved_secret()), + adapters={"http": adapter}, + ) + + with pytest.raises(MCPScopeInsufficientError) as excinfo: + await service.relay( + scope=_scope(), + namespace="custom", + name="acme-notion", + context=MCPCallContext(method="tools/call", target="write_page"), + body=b"{}", + headers={}, + ) + + assert excinfo.value.scopes == [] + + +@pytest.mark.asyncio +async def test_relay_403_without_insufficient_scope_challenge_passes_through_untouched(): + """D16: a plain auth rejection (`invalid_token`, no scope challenge) is the + upstream's own protocol-level result, not a gateway-authored refusal — it must + reach the caller byte-for-byte, not be reinterpreted as step-up.""" + dao = MockMCPEndpointsDAO() + await _oauth_endpoint(dao) + adapter = MockUpstreamAdapter( + result=_challenge_result(www_authenticate='Bearer error="invalid_token"') + ) + service = _relay_service( + mcp_endpoints_dao=dao, + resolver=MockResolver(secret=_resolved_secret()), + adapters={"http": adapter}, + ) + + result = await service.relay( + scope=_scope(), + namespace="custom", + name="acme-notion", + context=MCPCallContext(method="tools/call", target="write_page"), + body=b"{}", + headers={}, + ) + + assert result.status_code == 403 + + +@pytest.mark.asyncio +async def test_relay_scope_challenge_ignored_for_a_none_scheme_endpoint(): + """Only an OAuth endpoint can step up — a `none`-scheme endpoint has nothing to + grant more of, so a 403 from it (however shaped) is pass-through, not step-up.""" + dao = MockMCPEndpointsDAO() + await _custom_endpoint(dao) # auth_mode=NONE + adapter = MockUpstreamAdapter( + result=_challenge_result( + www_authenticate='Bearer error="insufficient_scope", scope="x"' + ) + ) + service = _relay_service(mcp_endpoints_dao=dao, adapters={"http": adapter}) + + result = await service.relay( + scope=_scope(), + namespace="custom", + name="acme-notion", + context=MCPCallContext(method="tools/call", target="write_page"), + body=b"{}", + headers={}, + ) + + assert result.status_code == 403 diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_utils.py b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_utils.py new file mode 100644 index 0000000000..f3e77973b3 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_mcp_utils.py @@ -0,0 +1,51 @@ +"""Unit tests for parse_mcp_call_context (entities.md §9, workstreams/specs-wp8.md). + +Pure function: header dicts in, MCPCallContext out. Header names pinned against the +2026-07-28 MCP revision: `MCP-Method` (required), `MCP-Name` (target, absent for +target-less methods). +""" + +import pytest + +from oss.src.apis.fastapi.gateways.mcps.utils import parse_mcp_call_context +from oss.src.core.gateways.mcps.dtos import MCPCallContext + + +def test_both_headers_present(): + context = parse_mcp_call_context( + headers={"MCP-Method": "tools/call", "MCP-Name": "echo"} + ) + + assert context == MCPCallContext(method="tools/call", target="echo") + + +def test_target_absent_for_a_target_less_method(): + context = parse_mcp_call_context(headers={"MCP-Method": "tools/list"}) + + assert context == MCPCallContext(method="tools/list", target=None) + + +def test_header_names_are_case_insensitive(): + context = parse_mcp_call_context( + headers={"mcp-method": "tools/call", "MCP-NAME": "echo"} + ) + + assert context == MCPCallContext(method="tools/call", target="echo") + + +def test_missing_method_header_raises_value_error(): + with pytest.raises(ValueError): + parse_mcp_call_context(headers={"MCP-Name": "echo"}) + + +def test_blank_method_header_raises_value_error(): + with pytest.raises(ValueError): + parse_mcp_call_context(headers={"MCP-Method": " "}) + + +def test_blank_target_header_is_treated_as_absent(): + context = parse_mcp_call_context( + headers={"MCP-Method": "tools/list", "MCP-Name": " "} + ) + + assert context.target is None diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_policy_audit.py b/api/oss/tests/pytest/unit/gateways/test_gateways_policy_audit.py new file mode 100644 index 0000000000..5e24a1bc0f --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_policy_audit.py @@ -0,0 +1,372 @@ +"""Unit tests for the gateway audit event (specs-wp4.md, D22). + +`_safe_publish` swallows failures internally, so mocking +`oss.src.core.events.utils.publish_event` is the seam: it is the last thing +called before the event reaches the (mocked-out) transport. +""" + +import json +from typing import AsyncIterator +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest + +from oss.src.core.access.permissions.types import Permission +from oss.src.core.events.types import EventType +from oss.src.core.gateways.dtos import GatewayEndpointNamespace +from oss.src.core.gateways.llms.dtos import ( + LLMDeploymentKind, + LLMEndpoint, + LLMEndpointData, + LLMEndpointSettings, + LLMModelFilter, +) +from oss.src.core.gateways.llms.interfaces import ( + LLMEndpointsDAOInterface, + LLMRelayResult, + LLMUpstreamInterface, +) +from oss.src.core.gateways.llms.registry import LLMUpstreamRegistry +from oss.src.core.gateways.llms.service import LLMGatewayService +from oss.src.core.gateways.policy.audit import ( + build_gateway_call_attributes, + publish_gateway_call, +) +from oss.src.core.gateways.policy.dtos import ( + GatewayOutcome, + GatewayPlane, + GatewayTarget, + PolicyDecision, + SecretOrigin, + SecretOwner, + SecretOwnerKind, +) +from oss.src.core.gateways.policy.service import GatewayPolicyService +from oss.src.utils.context import AuthScope + + +def _scope() -> AuthScope: + return AuthScope( + organization_id=uuid4(), + workspace_id=uuid4(), + project_id=uuid4(), + user_id=uuid4(), + ) + + +def _llm_target(**overrides) -> GatewayTarget: + defaults = dict( + plane=GatewayPlane.LLM, + namespace=GatewayEndpointNamespace.STANDARD, + name="openai", + model="gpt-4o", + ) + defaults.update(overrides) + return GatewayTarget(**defaults) + + +def _mcp_target(**overrides) -> GatewayTarget: + defaults = dict( + plane=GatewayPlane.MCP, + namespace=GatewayEndpointNamespace.BUILTIN, + name="notion", + provider="composio", + integration="notion", + method="tools/call", + tool="search", + ) + defaults.update(overrides) + return GatewayTarget(**defaults) + + +def _allowed(permission=Permission.USE_LLM_ENDPOINTS) -> PolicyDecision: + return PolicyDecision(allowed=True, permission=permission, reason=None) + + +def _denied(reason: str, permission=Permission.USE_LLM_ENDPOINTS) -> PolicyDecision: + return PolicyDecision(allowed=False, permission=permission, reason=reason) + + +# --- build_gateway_call_attributes ------------------------------------------ # + + +def test_attributes_carry_principal_target_decision_outcome(): + scope = _scope() + target = _llm_target(endpoint_id=uuid4()) + outcome = GatewayOutcome( + status_code=200, + owner=SecretOwner(kind=SecretOwnerKind.PROJECT), + origin=SecretOrigin.VAULT, + ) + + attributes = build_gateway_call_attributes( + scope=scope, target=target, decision=_allowed(), outcome=outcome + ) + + assert attributes["organization_id"] == str(scope.organization_id) + assert attributes["workspace_id"] == str(scope.workspace_id) + assert attributes["project_id"] == str(scope.project_id) + assert attributes["user_id"] == str(scope.user_id) + assert attributes["plane"] == "llm" + assert attributes["namespace"] == "standard" + assert attributes["name"] == "openai" + assert attributes["endpoint_id"] == str(target.endpoint_id) + assert attributes["model"] == "gpt-4o" + assert attributes["allowed"] is True + assert "reason" not in attributes + assert attributes["status_code"] == 200 + assert attributes["secret_origin"] == "vault" + + +def test_attributes_carry_denial_reason(): + attributes = build_gateway_call_attributes( + scope=_scope(), + target=_llm_target(), + decision=_denied("model_not_allowed"), + outcome=GatewayOutcome(status_code=403), + ) + + assert attributes["allowed"] is False + assert attributes["reason"] == "model_not_allowed" + assert attributes["status_code"] == 403 + assert "secret_origin" not in attributes + + +def test_pass_through_call_leaves_secret_origin_unset(): + """No secret resolved (pass-through) — `secret_origin` distinguishes a call + we funded from one the caller did (specs-wp4.md).""" + attributes = build_gateway_call_attributes( + scope=_scope(), + target=_llm_target(), + decision=_allowed(), + outcome=GatewayOutcome(status_code=200, owner=None, origin=None), + ) + + assert "secret_origin" not in attributes + + +def test_attributes_carry_no_request_or_response_body_value(): + attributes = build_gateway_call_attributes( + scope=_scope(), + target=_mcp_target(), + decision=_allowed(permission=Permission.USE_MCP_ENDPOINTS), + outcome=GatewayOutcome(status_code=200, origin=SecretOrigin.LOCAL), + ) + + forbidden = { + "prompt", + "completion", + "body", + "headers", + "secret", + "x-ag-credentials", + } + assert forbidden.isdisjoint({key.lower() for key in attributes}) + for value in attributes.values(): + assert "X-AG-Credentials" not in str(value) + + +# --- publish_gateway_call ---------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_publish_gateway_call_emits_one_event_llm_plane(): + publish = AsyncMock() + with patch("oss.src.core.events.utils.publish_event", new=publish): + await publish_gateway_call( + scope=_scope(), + target=_llm_target(), + decision=_allowed(), + outcome=GatewayOutcome(status_code=200, origin=SecretOrigin.VAULT), + ) + + publish.assert_awaited_once() + event = publish.await_args.kwargs["event"] + assert event.event_type == EventType.GATEWAYS_CALLED + assert event.attributes["plane"] == "llm" + assert event.attributes["allowed"] is True + + +@pytest.mark.asyncio +async def test_publish_gateway_call_emits_one_event_mcp_plane(): + publish = AsyncMock() + with patch("oss.src.core.events.utils.publish_event", new=publish): + await publish_gateway_call( + scope=_scope(), + target=_mcp_target(), + decision=_allowed(permission=Permission.USE_MCP_ENDPOINTS), + outcome=GatewayOutcome(status_code=200, origin=SecretOrigin.LOCAL), + ) + + publish.assert_awaited_once() + event = publish.await_args.kwargs["event"] + assert event.event_type == EventType.GATEWAYS_CALLED + assert event.attributes["plane"] == "mcp" + + +@pytest.mark.asyncio +async def test_publish_gateway_call_records_denial_exactly_once(): + publish = AsyncMock() + with patch("oss.src.core.events.utils.publish_event", new=publish): + await publish_gateway_call( + scope=_scope(), + target=_llm_target(), + decision=_denied("model_not_allowed"), + outcome=GatewayOutcome(status_code=403), + ) + + publish.assert_awaited_once() + event = publish.await_args.kwargs["event"] + assert event.attributes["allowed"] is False + assert event.attributes["reason"] == "model_not_allowed" + + +@pytest.mark.asyncio +async def test_publish_gateway_call_swallows_publisher_failure(): + async def _raise(**_kwargs): + raise RuntimeError("redis down") + + with patch("oss.src.core.events.utils.publish_event", new=_raise): + # Must not raise. + await publish_gateway_call( + scope=_scope(), + target=_llm_target(), + decision=_allowed(), + outcome=GatewayOutcome(status_code=200), + ) + + +# --- through the service, on the deny path ----------------------------------- # + + +@pytest.mark.asyncio +async def test_record_denied_call_publishes_and_deny_still_raisable(monkeypatch): + """`record()` itself never raises `PolicyDeniedError` — the relay raises it + after calling `record()`. This asserts the audit half: the relay's own + exception behaviour is exercised at the relay call sites, not here.""" + publish = AsyncMock() + monkeypatch.setattr("oss.src.core.events.utils.publish_event", publish) + + service = GatewayPolicyService(resolver=AsyncMock()) + result = await service.record( + scope=_scope(), + target=_llm_target(), + decision=_denied("permission_denied"), + outcome=GatewayOutcome(status_code=403), + ) + + assert result is None + publish.assert_awaited_once() + event = publish.await_args.kwargs["event"] + assert event.attributes["reason"] == "permission_denied" + + +@pytest.mark.asyncio +async def test_record_does_not_raise_when_publisher_raises(monkeypatch): + async def _raise(**_kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr("oss.src.core.events.utils.publish_event", _raise) + + service = GatewayPolicyService(resolver=AsyncMock()) + result = await service.record( + scope=_scope(), + target=_llm_target(), + decision=_allowed(), + outcome=GatewayOutcome(status_code=200), + ) + + assert result is None + + +# --- through a real relay: publisher failure must not touch the result ------ # + + +class _PassthroughDAO(LLMEndpointsDAOInterface): + def __init__(self, row: LLMEndpoint): + self._row = row + + async def create_endpoint(self, *, project_id, user_id, endpoint): + raise NotImplementedError + + async def fetch_endpoint(self, *, project_id, endpoint_id): + raise NotImplementedError + + async def fetch_endpoint_by_slug(self, *, project_id, slug): + return self._row if slug == self._row.slug else None + + async def edit_endpoint(self, *, project_id, user_id, endpoint): + raise NotImplementedError + + async def delete_endpoint(self, *, project_id, endpoint_id): + raise NotImplementedError + + async def query_endpoints(self, *, project_id, endpoint=None, windowing=None): + return [] + + +class _PassthroughAdapter(LLMUpstreamInterface): + def __init__(self, result: LLMRelayResult): + self._result = result + + async def relay_chat_completion(self, *, route, secret, context, body, headers): + return self._result + + +async def _one_chunk(data: bytes) -> AsyncIterator[bytes]: + yield data + + +@pytest.mark.asyncio +async def test_relay_result_unaffected_when_publisher_raises(monkeypatch): + """A publisher that raises does not propagate — the relay's own result is + unaffected, not merely "no exception escaped" (specs-wp4.md).""" + + async def _raise(**_kwargs): + raise RuntimeError("redis down") + + monkeypatch.setattr("oss.src.core.events.utils.publish_event", _raise) + monkeypatch.setattr( + "oss.src.core.gateways.policy.service.check_action_access", + AsyncMock(return_value=True), + ) + + row = LLMEndpoint( + id=uuid4(), + slug="acme", + provider_key="openai", + deployment_kind=LLMDeploymentKind.CUSTOM, + namespace=GatewayEndpointNamespace.CUSTOM, + secret_id=None, # no secret bound: pass-through, nothing to resolve + data=LLMEndpointData( + models=LLMModelFilter(allowlist=["gpt-4o"]), + settings=LLMEndpointSettings(), + ), + ) + adapter_result = LLMRelayResult( + status_code=200, + headers={}, + body=_one_chunk(b'{"ok": true}'), + ) + service = LLMGatewayService( + llm_endpoints_dao=_PassthroughDAO(row), + policy=GatewayPolicyService(resolver=AsyncMock()), + resolver=AsyncMock(), + upstream_registry=LLMUpstreamRegistry( + adapters={"relay": _PassthroughAdapter(adapter_result)} + ), + ) + + result = await service.relay_chat_completion( + scope=_scope(), + namespace=GatewayEndpointNamespace.CUSTOM, + name="acme", + body=json.dumps({"model": "gpt-4o", "messages": []}).encode(), + headers={}, + ) + + assert result is adapter_result + assert result.status_code == 200 + # Draining the body is where record() (and the raising publisher) fires. + assert [chunk async for chunk in result.body] == [b'{"ok": true}'] diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_policy_service.py b/api/oss/tests/pytest/unit/gateways/test_gateways_policy_service.py new file mode 100644 index 0000000000..e9c87526d4 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_policy_service.py @@ -0,0 +1,165 @@ +"""Unit tests for GatewayPolicyService (specs-wp3.md). + +`check_action_access` is mocked at the module boundary +(`oss.src.core.gateways.policy.service.check_action_access`) — it is +`authorize()`'s only dependency. No entitlement check exists (D29), so there is +nothing else to mock and no "entitlement denied" arm to exercise here. +""" + +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from oss.src.core.access.permissions.types import DefaultRole, Permission +from oss.src.core.gateways.dtos import GatewayEndpointNamespace +from oss.src.core.gateways.policy.dtos import ( + GatewayOutcome, + GatewayPlane, + GatewayTarget, + PolicyDecision, +) +from oss.src.core.gateways.policy.service import GatewayPolicyService +from oss.src.utils.context import AuthScope + + +def _scope() -> AuthScope: + return AuthScope( + organization_id=uuid4(), + workspace_id=uuid4(), + project_id=uuid4(), + user_id=uuid4(), + ) + + +def _target() -> GatewayTarget: + return GatewayTarget( + plane=GatewayPlane.LLM, + namespace=GatewayEndpointNamespace.BUILTIN, + name="openai", + model="gpt-4o", + ) + + +def _service() -> GatewayPolicyService: + return GatewayPolicyService(resolver=AsyncMock()) + + +@pytest.mark.asyncio +async def test_authorize_allows_when_check_action_access_true(monkeypatch): + monkeypatch.setattr( + "oss.src.core.gateways.policy.service.check_action_access", + AsyncMock(return_value=True), + ) + + decision = await _service().authorize( + scope=_scope(), permission=Permission.USE_LLM_ENDPOINTS, target=_target() + ) + + assert decision == PolicyDecision( + allowed=True, permission=Permission.USE_LLM_ENDPOINTS, reason=None + ) + + +@pytest.mark.asyncio +async def test_authorize_denies_when_check_action_access_false(monkeypatch): + monkeypatch.setattr( + "oss.src.core.gateways.policy.service.check_action_access", + AsyncMock(return_value=False), + ) + + decision = await _service().authorize( + scope=_scope(), permission=Permission.USE_LLM_ENDPOINTS, target=_target() + ) + + assert decision.allowed is False + assert decision.reason == "permission_denied" + + +@pytest.mark.asyncio +async def test_authorize_denies_when_check_action_access_raises(monkeypatch): + """Fails closed AND returns: a decision the caller can record, never a 500 that + skips the audit event (entities.md §8's "raises nothing").""" + + async def _raise(**_kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr( + "oss.src.core.gateways.policy.service.check_action_access", _raise + ) + + decision = await _service().authorize( + scope=_scope(), permission=Permission.USE_LLM_ENDPOINTS, target=_target() + ) + + assert decision.allowed is False + assert decision.reason == "permission_check_failed" + + +@pytest.mark.asyncio +async def test_record_publishes_one_event(monkeypatch): + publish = AsyncMock() + monkeypatch.setattr("oss.src.core.events.utils.publish_event", publish) + + result = await _service().record( + scope=_scope(), + target=_target(), + decision=PolicyDecision( + allowed=True, permission=Permission.USE_LLM_ENDPOINTS, reason=None + ), + outcome=GatewayOutcome(status_code=200), + ) + + assert result is None + publish.assert_awaited_once() + + +# --- role wiring (entities.md §9) -------------------------------------------- # + + +def test_viewer_gains_view_llm_and_mcp_endpoints(): + permissions = Permission.default_permissions(DefaultRole.VIEWER) + assert Permission.VIEW_LLM_ENDPOINTS in permissions + assert Permission.VIEW_MCP_ENDPOINTS in permissions + assert Permission.USE_LLM_ENDPOINTS not in permissions + assert Permission.EDIT_LLM_ENDPOINTS not in permissions + + +def test_annotator_gains_use_llm_and_mcp_endpoints_on_top_of_viewer(): + permissions = Permission.default_permissions(DefaultRole.ANNOTATOR) + assert Permission.VIEW_LLM_ENDPOINTS in permissions + assert Permission.VIEW_MCP_ENDPOINTS in permissions + assert Permission.USE_LLM_ENDPOINTS in permissions + assert Permission.USE_MCP_ENDPOINTS in permissions + assert Permission.EDIT_LLM_ENDPOINTS not in permissions + assert Permission.EDIT_MCP_ENDPOINTS not in permissions + + +def test_editor_gains_edit_llm_and_mcp_endpoints_on_top_of_annotator(): + permissions = Permission.default_permissions(DefaultRole.EDITOR) + for member in ( + Permission.VIEW_LLM_ENDPOINTS, + Permission.VIEW_MCP_ENDPOINTS, + Permission.USE_LLM_ENDPOINTS, + Permission.USE_MCP_ENDPOINTS, + Permission.EDIT_LLM_ENDPOINTS, + Permission.EDIT_MCP_ENDPOINTS, + ): + assert member in permissions + + +@pytest.mark.parametrize( + "role", + [DefaultRole.DEVELOPER, DefaultRole.ADMIN, DefaultRole.OWNER], +) +def test_superset_roles_propagate_all_six_members(role): + permissions = Permission.default_permissions(role) + for member in ( + Permission.VIEW_LLM_ENDPOINTS, + Permission.VIEW_MCP_ENDPOINTS, + Permission.USE_LLM_ENDPOINTS, + Permission.USE_MCP_ENDPOINTS, + Permission.EDIT_LLM_ENDPOINTS, + Permission.EDIT_MCP_ENDPOINTS, + ): + assert member in permissions diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_resolution.py b/api/oss/tests/pytest/unit/gateways/test_gateways_resolution.py new file mode 100644 index 0000000000..b2b9311275 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_resolution.py @@ -0,0 +1,260 @@ +"""Unit tests for `SecretsResolver` (specs-wp2.md, tasks-wp2.md). + +Every case below runs against a dict-backed mock `VaultService` and a dict-backed mock +the vault service — no Postgres, no Redis, no encryption key. The mode-table cases +(`USER_REQUIRED` never falls back, `USER_OPTIONAL` names the narrower owner) are the point +of this suite; everything else exists to pin the ref-arm matching rules. +""" + +from typing import Dict, List, Optional, Tuple +from uuid import UUID, uuid4 + +import pytest + +from oss.src.core.gateways.policy.dtos import ( + BoundSecretRef, + SecretMode, + SecretOwnerKind, + ProviderKeyRef, + SecretOrigin, +) +from oss.src.core.gateways.policy.resolution import SecretsResolver +from oss.src.core.gateways.policy.types import ( + SecretNotFoundError, +) +from oss.src.core.secrets.dtos import ( + CustomProviderDTO, + CustomProviderSettingsDTO, + CustomSecretDTO, + CustomSecretSettingsDTO, + SecretResponseDTO, + StandardProviderDTO, + StandardProviderSettingsDTO, +) +from oss.src.core.secrets.enums import ( + CustomProviderKind, + CustomSecretFormat, + SecretKind, + StandardProviderKind, +) +from oss.src.core.shared.dtos import Header +from oss.src.utils.context import AuthScope + +ALL_MODES = [ + SecretMode.PROJECT_ONLY, + SecretMode.USER_REQUIRED, + SecretMode.USER_OPTIONAL, +] + + +# --- mocks (WP2 must not subclass the real VaultService / DAO) ---------------- # + + +class MockVaultService: + """In-memory secret_id -> SecretResponseDTO map; implements only the two + VaultService methods SecretsResolver calls.""" + + def __init__(self, secrets: Optional[List[SecretResponseDTO]] = None) -> None: + self._by_id: Dict[UUID, SecretResponseDTO] = {s.id: s for s in secrets or []} + self.get_secret_by_id_calls = 0 + + async def list_secrets(self, project_id=None, organization_id=None): + return list(self._by_id.values()) + + async def get_secret_by_id(self, secret_id, project_id=None, organization_id=None): + self.get_secret_by_id_calls += 1 + return self._by_id.get(secret_id) + + +def _scope(*, user_id: Optional[UUID] = None) -> AuthScope: + return AuthScope( + organization_id=uuid4(), + workspace_id=uuid4(), + project_id=uuid4(), + user_id=user_id or uuid4(), + ) + + +def _bound_secret() -> SecretResponseDTO: + return SecretResponseDTO( + id=uuid4(), + kind=SecretKind.CUSTOM_SECRET, + data=CustomSecretDTO( + secret=CustomSecretSettingsDTO( + format=CustomSecretFormat.TEXT, content="bound-secret-value" + ) + ), + header=Header(name="bound"), + ) + + +def _provider_key_secret( + provider: StandardProviderKind, *, key: str = "sk-test" +) -> SecretResponseDTO: + return SecretResponseDTO( + id=uuid4(), + kind=SecretKind.PROVIDER_KEY, + data=StandardProviderDTO( + kind=provider, provider=StandardProviderSettingsDTO(key=key) + ), + header=Header(name=provider.value), + ) + + +def _custom_provider_secret( + provider: CustomProviderKind, *, key: str = "ck-test" +) -> SecretResponseDTO: + # SecretResponseDTO's own before-validator (build_up_model_keys) special-cases + # CUSTOM_PROVIDER and expects a dict, unlike SecretDTO's — pass a dict, not the + # model instance, so it doesn't see a CustomProviderDTO where it calls .get(...). + data = CustomProviderDTO( + kind=provider, + provider=CustomProviderSettingsDTO(key=key), + models=[], + ).model_dump() + return SecretResponseDTO( + id=uuid4(), + kind=SecretKind.CUSTOM_PROVIDER, + data=data, + header=Header(name=provider.value), + ) + + +def _resolver(*, secrets=None) -> Tuple[SecretsResolver, MockVaultService]: + vault = MockVaultService(secrets) + return SecretsResolver(vault_service=vault), vault + + +# --- BoundSecretRef -------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_bound_secret_resolves_when_present(): + secret = SecretResponseDTO( + id=uuid4(), + kind=SecretKind.PROVIDER_KEY, + data=StandardProviderDTO( + kind=StandardProviderKind.OPENAI, + provider=StandardProviderSettingsDTO(key="sk-test"), + ), + header=Header(name="openai"), + ) + resolver, _vault = _resolver(secrets=[secret]) + scope = _scope() + + resolved = await resolver.resolve( + scope=scope, + ref=BoundSecretRef(secret_id=secret.id), + mode=SecretMode.PROJECT_ONLY, + ) + + assert resolved.secret.id == secret.id + assert resolved.owner.kind == SecretOwnerKind.PROJECT + assert resolved.origin == SecretOrigin.VAULT + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ALL_MODES) +async def test_bound_secret_missing_raises_for_every_mode(mode): + resolver, _vault = _resolver() + scope = _scope() + missing_id = uuid4() + + with pytest.raises(SecretNotFoundError) as excinfo: + await resolver.resolve( + scope=scope, ref=BoundSecretRef(secret_id=missing_id), mode=mode + ) + + assert excinfo.value.missing == SecretOwnerKind.PROJECT + assert excinfo.value.mode == mode + assert excinfo.value.target == f"secret:{missing_id}" + + +# --- ProviderKeyRef --------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_provider_key_match_resolves(): + secret = _provider_key_secret(StandardProviderKind.OPENAI) + resolver, _vault = _resolver(secrets=[secret]) + + resolved = await resolver.resolve( + scope=_scope(), + ref=ProviderKeyRef(provider_key="openai"), + mode=SecretMode.PROJECT_ONLY, + ) + + assert resolved.secret.id == secret.id + assert resolved.owner.kind == SecretOwnerKind.PROJECT + assert resolved.origin == SecretOrigin.VAULT + + +@pytest.mark.asyncio +async def test_provider_key_falls_back_to_custom_provider_when_no_provider_key_match(): + custom = _custom_provider_secret(CustomProviderKind.AZURE) + resolver, _vault = _resolver(secrets=[custom]) + + resolved = await resolver.resolve( + scope=_scope(), + ref=ProviderKeyRef(provider_key="azure"), + mode=SecretMode.PROJECT_ONLY, + ) + + assert resolved.secret.id == custom.id + + +@pytest.mark.asyncio +async def test_provider_key_prefers_provider_key_kind_over_custom_provider(): + standard = _provider_key_secret(StandardProviderKind.OPENAI) + # Matches the same provider name via the CustomProviderKind arm too. + custom = _custom_provider_secret(CustomProviderKind.OPENAI) + resolver, _vault = _resolver(secrets=[custom, standard]) + + resolved = await resolver.resolve( + scope=_scope(), + ref=ProviderKeyRef(provider_key="openai"), + mode=SecretMode.PROJECT_ONLY, + ) + + assert resolved.secret.id == standard.id + assert resolved.secret.kind == SecretKind.PROVIDER_KEY + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ALL_MODES) +async def test_provider_key_no_match_raises_for_every_mode(mode): + resolver, _vault = _resolver() + + with pytest.raises(SecretNotFoundError) as excinfo: + await resolver.resolve( + scope=_scope(), ref=ProviderKeyRef(provider_key="openai"), mode=mode + ) + + assert excinfo.value.missing == SecretOwnerKind.PROJECT + assert excinfo.value.target == "provider:openai" + + +# --- available_provider_keys (R2) ------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_available_provider_keys_returns_names_across_both_kinds(): + resolver, _vault = _resolver( + secrets=[ + _provider_key_secret(StandardProviderKind.OPENAI), + _custom_provider_secret(CustomProviderKind.AZURE), + ] + ) + + keys = await resolver.available_provider_keys(scope=_scope()) + + assert keys == {"openai", "azure"} + + +@pytest.mark.asyncio +async def test_available_provider_keys_empty_project_returns_empty_set_without_raising(): + resolver, _vault = _resolver() + + keys = await resolver.available_provider_keys(scope=_scope()) + + assert keys == set() diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_seed.py b/api/oss/tests/pytest/unit/gateways/test_gateways_seed.py new file mode 100644 index 0000000000..20edf0f669 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_seed.py @@ -0,0 +1,522 @@ +"""Seed smoke test for the gateways domain (entities.md, wave 0). + +Every function/method body in the seed is `raise NotImplementedError`; this test only +proves the declarations import cleanly and every DTO is constructible with representative +values — no DB, no Redis, no API. Behavioural coverage lands with each work package. +""" + +from dataclasses import fields +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from oss.src.core.access.permissions.types import Permission +from oss.src.core.gateway.connections.dtos import Connection, ConnectionProviderKind +from oss.src.core.secrets.dtos import ( + SecretResponseDTO, + StandardProviderDTO, + StandardProviderSettingsDTO, +) +from oss.src.core.secrets.enums import SecretKind, StandardProviderKind +from oss.src.core.shared.dtos import Header + +from oss.src.core.gateways.dtos import ( + GatewayAuthScheme, + GatewayConnectAffordance, + GatewayConnectionRequirement, + GatewayConnectionState, + GatewayEndpointSettings, + GatewayEndpointNamespace, +) +from oss.src.core.gateways.types import GatewaysError + +from oss.src.core.gateways.policy.dtos import ( + BoundSecretRef, + SecretMode, + SecretOwner, + SecretOwnerKind, + GatewayOutcome, + GatewayPlane, + GatewayTarget, + GatewayUsage, + PolicyDecision, + ProviderKeyRef, + ResolvedSecret, + SecretOrigin, +) +from oss.src.core.gateways.policy.types import ( + CeilingExceededError, + SecretInvalidError, + SecretNotFoundError, + EntitlementDeniedError, + PolicyDeniedError, +) +from oss.src.core.gateways.policy.interfaces import SecretsResolverInterface + +from oss.src.core.gateways.llms.dtos import ( + LLMCallContext, + LLMDeploymentKind, + LLMEndpoint, + LLMEndpointCreate, + LLMEndpointData, + LLMEndpointEdit, + LLMEndpointFlags, + LLMEndpointQuery, + LLMEndpointRoute, + LLMEndpointSettings, + LLMModelFilter, + LLMResolvedRoute, +) +from oss.src.core.gateways.llms.types import ( + LLMEndpointNotFoundError, + LLMModelNotAllowedError, + LLMUpstreamError, +) +from oss.src.core.gateways.llms.interfaces import ( + LLMEndpointsDAOInterface, + LLMRelayResult, + LLMUpstreamInterface, +) + +from oss.src.core.gateways.mcps.dtos import ( + MCPBrokeredAuth, + MCPCallContext, + MCPDirectAuth, + MCPEndpoint, + MCPEndpointSettings, + MCPEndpointCreate, + MCPEndpointData, + MCPEndpointEdit, + MCPEndpointFlags, + MCPEndpointQuery, + MCPOAuthData, + MCPResolvedRoute, + MCPEndpointRoute, + MCPToolFilter, +) +from oss.src.core.gateways.mcps.types import ( + MCPAuthRequiredError, + MCPEndpointNotFoundError, + MCPScopeInsufficientError, + MCPToolNotAllowedError, + MCPUpstreamError, +) +from oss.src.core.gateways.mcps.interfaces import ( + MCPEndpointsDAOInterface, + MCPRelayResult, + MCPUpstreamInterface, +) + +from oss.src.apis.fastapi.gateways.exceptions import handle_gateway_exceptions + + +# --- shared vocabulary (already-done files, exercised transitively) ---------- # + + +def test_gateway_dtos(): + assert GatewayAuthScheme.NONE.value == "none" + assert GatewayConnectionState.READY.value == "ready" + affordance = GatewayConnectAffordance(endpoint="/gateways/mcps/custom/acme/connect") + requirement = GatewayConnectionRequirement( + target="custom/acme", + state=GatewayConnectionState.NEEDS_AUTH, + connect=affordance, + ) + assert requirement.connect is affordance + assert GatewayEndpointNamespace.BUILTIN.value == "builtin" + settings = GatewayEndpointSettings(timeout_seconds=30.0) + assert settings.timeout_seconds == 30.0 + + +def test_gateways_error(): + err = GatewaysError() + assert err.message == "Gateways error" + + +# --- policy core -------------------------------------------------------------- # + + +def _standard_secret() -> SecretResponseDTO: + return SecretResponseDTO( + id=uuid4(), + kind=SecretKind.PROVIDER_KEY, + data=StandardProviderDTO( + kind=StandardProviderKind.OPENAI, + provider=StandardProviderSettingsDTO(key="sk-test"), + ), + header=Header(name="openai"), + ) + + +def test_policy_dtos(): + assert GatewayPlane.LLM.value == "llm" + assert SecretMode.USER_OPTIONAL.value == "user_optional" + owner = SecretOwner(kind=SecretOwnerKind.PROJECT) + assert owner.user_id is None + assert SecretOrigin.VAULT.value == "vault" + + assert ProviderKeyRef(provider_key="openai").provider_key == "openai" + assert BoundSecretRef(secret_id=uuid4()).secret_id is not None + + secret = ResolvedSecret( + secret=_standard_secret(), + owner=owner, + origin=SecretOrigin.LOCAL, + ) + assert secret.origin == SecretOrigin.LOCAL + + target = GatewayTarget( + plane=GatewayPlane.LLM, + namespace=GatewayEndpointNamespace.BUILTIN, + name="openai", + model="gpt-4o", + ) + decision = PolicyDecision(allowed=True, permission=Permission.VIEW_SECRET) + usage = GatewayUsage(calls=1, input_tokens=10, output_tokens=5) + outcome = GatewayOutcome( + status_code=200, usage=usage, owner=owner, origin=SecretOrigin.VAULT + ) + assert outcome.usage is usage + assert target.model == "gpt-4o" + assert decision.allowed is True + + +def test_policy_exceptions(): + denied = PolicyDeniedError(permission=Permission.VIEW_SECRET, target="custom/acme") + assert denied.permission == Permission.VIEW_SECRET + + entitlement = EntitlementDeniedError(key="llm_calls", target="builtin/openai") + assert entitlement.key == "llm_calls" + + not_found = SecretNotFoundError( + mode=SecretMode.PROJECT_ONLY, + missing=SecretOwnerKind.PROJECT, + target="builtin/openai", + ) + assert not_found.missing == SecretOwnerKind.PROJECT + + invalid = SecretInvalidError(target="custom/acme", detail="refresh failed") + assert invalid.detail == "refresh failed" + + ceiling = CeilingExceededError( + ceiling="max_output_tokens", requested=8192, allowed=4096, target="custom/acme" + ) + assert ceiling.requested == 8192 + + +def test_secret_resolver_interface_is_abstract_with_two_methods(): + assert SecretsResolverInterface.__abstractmethods__ == frozenset( + {"resolve", "available_provider_keys"} + ) + with pytest.raises(TypeError): + SecretsResolverInterface() + + +# --- LLM plane ------------------------------------------------------------- # + + +def test_llm_dtos(): + route = LLMEndpointRoute(base_url="http://mock-llm-gateway:9091/v1") + settings = LLMEndpointSettings(max_output_tokens=4096) + data = LLMEndpointData( + route=route, models=LLMModelFilter(allowlist=["gpt-4o"]), settings=settings + ) + flags = LLMEndpointFlags() + + endpoint = LLMEndpoint( + id=uuid4(), + slug="acme-azure", + provider_key="azure", + deployment_kind=LLMDeploymentKind.AZURE, + data=data, + flags=flags, + ) + assert endpoint.namespace == GatewayEndpointNamespace.CUSTOM + + create = LLMEndpointCreate( + slug="acme-azure", + provider_key="azure", + deployment_kind=LLMDeploymentKind.AZURE, + data=data, + ) + assert create.deployment_kind == LLMDeploymentKind.AZURE + + edit = LLMEndpointEdit(id=uuid4(), data=data, flags=flags) + assert edit.data is data + + query = LLMEndpointQuery( + provider_key="azure", deployment_kind=LLMDeploymentKind.AZURE + ) + assert query.slug is None + + context = LLMCallContext(model="gpt-4o", stream=True) + assert context.stream is True + + resolved = LLMResolvedRoute( + provider_key="azure", + deployment_kind=LLMDeploymentKind.AZURE, + model="gpt-4o", + base_url="http://mock-llm-gateway:9091/azure", + settings=settings, + ) + assert resolved.model == "gpt-4o" + + +def test_llm_exceptions(): + not_found = LLMEndpointNotFoundError( + namespace=GatewayEndpointNamespace.CUSTOM, name="acme-azure" + ) + assert "acme-azure" in not_found.message + + not_allowed = LLMModelNotAllowedError( + model="gpt-5", namespace=GatewayEndpointNamespace.CUSTOM, name="acme-azure" + ) + assert not_allowed.model == "gpt-5" + + upstream = LLMUpstreamError(provider_key="azure", status_code=500, detail="boom") + assert upstream.status_code == 500 + + +@pytest.mark.asyncio +async def test_llm_relay_result_and_ports(): + async def _body(): + yield b"chunk" + + result = LLMRelayResult(status_code=200, headers={}, body=_body()) + assert {f.name for f in fields(result)} == { + "status_code", + "headers", + "body", + "usage", + } + + assert LLMEndpointsDAOInterface.__abstractmethods__ == frozenset( + { + "create_endpoint", + "fetch_endpoint", + "fetch_endpoint_by_slug", + "edit_endpoint", + "delete_endpoint", + "query_endpoints", + } + ) + assert LLMUpstreamInterface.__abstractmethods__ == frozenset( + {"relay_chat_completion"} + ) + + +# --- MCP plane --------------------------------------------------------------- # + + +def test_mcp_dtos(): + tools = MCPToolFilter(allowlist=["search"]) + settings = MCPEndpointSettings(timeout_seconds=10.0) + oauth = MCPOAuthData( + resource="https://mcp.acme.com", authorization_server="https://auth.acme.com" + ) + data = MCPEndpointData( + route=MCPEndpointRoute(base_url="https://mcp.acme.com"), + tools=tools, + settings=settings, + oauth=oauth, + ) + flags = MCPEndpointFlags() + + endpoint = MCPEndpoint( + id=uuid4(), + slug="acme-notion", + auth_mode=GatewayAuthScheme.OAUTH, + data=data, + flags=flags, + ) + assert endpoint.namespace == GatewayEndpointNamespace.CUSTOM + + create = MCPEndpointCreate( + slug="acme-notion", auth_mode=GatewayAuthScheme.OAUTH, data=data + ) + assert create.data is data + + edit = MCPEndpointEdit(id=uuid4(), auth_mode=GatewayAuthScheme.NONE, data=data) + assert edit.auth_mode == GatewayAuthScheme.NONE + + query = MCPEndpointQuery(auth_mode=GatewayAuthScheme.OAUTH) + assert query.slug is None + + call_context = MCPCallContext(method="tools/call", target="acme-notion") + assert call_context.method == "tools/call" + + resolved_route = MCPResolvedRoute( + url="https://mcp.acme.com", headers={"x": "y"}, settings=settings + ) + assert resolved_route.url == "https://mcp.acme.com" + + direct_auth = MCPDirectAuth( + secret=ResolvedSecret( + secret=_standard_secret(), + owner=SecretOwner(kind=SecretOwnerKind.PROJECT), + origin=SecretOrigin.VAULT, + ) + ) + assert direct_auth.secret is not None + + connection = Connection( + id=uuid4(), + slug="my-notion", + provider_key=ConnectionProviderKind.COMPOSIO, + integration_key="notion", + ) + brokered_auth = MCPBrokeredAuth(connection=connection) + assert brokered_auth.connection.integration_key == "notion" + + +def test_mcp_exceptions(): + not_found = MCPEndpointNotFoundError( + namespace=GatewayEndpointNamespace.BUILTIN, + name="my-notion", + provider="composio", + integration="notion", + ) + assert "builtin/composio/notion/my-notion" in not_found.message + + not_allowed = MCPToolNotAllowedError( + tool="search", namespace=GatewayEndpointNamespace.CUSTOM, name="acme-notion" + ) + assert not_allowed.tool == "search" + + requirement = GatewayConnectionRequirement( + target="custom/acme-notion", state=GatewayConnectionState.NEEDS_AUTH + ) + auth_required = MCPAuthRequiredError(requirement=requirement) + assert auth_required.requirement is requirement + + scope_insufficient = MCPScopeInsufficientError( + target="custom/acme-notion", scopes=["notion:write"] + ) + assert scope_insufficient.scopes == ["notion:write"] + + upstream = MCPUpstreamError(target="custom/acme-notion", status_code=502) + assert upstream.status_code == 502 + + +def test_mcp_relay_result_and_ports(): + result = MCPRelayResult(status_code=200, headers={}, body=b"{}") + assert {f.name for f in fields(result)} == {"status_code", "headers", "body"} + + assert MCPEndpointsDAOInterface.__abstractmethods__ == frozenset( + { + "create_endpoint", + "fetch_endpoint", + "fetch_endpoint_by_slug", + "edit_endpoint", + "delete_endpoint", + "query_endpoints", + } + ) + assert MCPUpstreamInterface.__abstractmethods__ == frozenset({"relay"}) + + +# --- API boundary -------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_handle_gateway_exceptions_passes_through(): + @handle_gateway_exceptions() + async def _handler(): + return "ok" + + assert await _handler() == "ok" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "raised, expected_status", + [ + ( + LLMEndpointNotFoundError( + namespace=GatewayEndpointNamespace.CUSTOM, name="acme" + ), + 404, + ), + ( + MCPEndpointNotFoundError( + namespace=GatewayEndpointNamespace.CUSTOM, name="acme" + ), + 404, + ), + # USE_MOUNTS stands in: the six gateway members are WP3's edit, not the seed's. + (PolicyDeniedError(permission=Permission.USE_MOUNTS, target="t"), 403), + (EntitlementDeniedError(key="k", target="t"), 403), + ( + LLMModelNotAllowedError( + model="m", namespace=GatewayEndpointNamespace.CUSTOM, name="acme" + ), + 403, + ), + ( + MCPToolNotAllowedError( + tool="t", namespace=GatewayEndpointNamespace.CUSTOM, name="acme" + ), + 403, + ), + ( + CeilingExceededError( + ceiling="max_output_tokens", requested=100, allowed=10, target="t" + ), + 400, + ), + (SecretInvalidError(target="t"), 409), + (MCPScopeInsufficientError(target="t", scopes=["a"]), 409), + (LLMUpstreamError(provider_key="openai", status_code=503), 502), + (LLMUpstreamError(provider_key="openai", status_code=429), 424), + (LLMUpstreamError(provider_key="openai"), 424), + (MCPUpstreamError(target="t", status_code=500), 502), + ], +) +async def test_handle_gateway_exceptions_mapping(raised, expected_status): + @handle_gateway_exceptions() + async def _handler(): + raise raised + + with pytest.raises(HTTPException) as excinfo: + await _handler() + assert excinfo.value.status_code == expected_status + + +@pytest.mark.asyncio +async def test_ceiling_denial_names_all_three_numbers(): + """D25: rejection is tolerable only because the denial says what to retry with.""" + + @handle_gateway_exceptions() + async def _handler(): + raise CeilingExceededError( + ceiling="max_output_tokens", requested=4096, allowed=1024, target="t" + ) + + with pytest.raises(HTTPException) as excinfo: + await _handler() + detail = excinfo.value.detail + assert detail["ceiling"] == "max_output_tokens" + assert detail["requested"] == 4096 + assert detail["allowed"] == 1024 + + +@pytest.mark.asyncio +async def test_auth_required_carries_the_connect_affordance(): + """D17: an interaction, not a failure — the 409 must carry the requirement.""" + requirement = GatewayConnectionRequirement( + target="custom/acme", + state=GatewayConnectionState.NEEDS_AUTH, + connect=GatewayConnectAffordance( + endpoint="/gateways/mcps/custom/acme/connect", + ), + ) + + @handle_gateway_exceptions() + async def _handler(): + raise MCPAuthRequiredError(requirement=requirement) + + with pytest.raises(HTTPException) as excinfo: + await _handler() + assert excinfo.value.status_code == 409 + assert excinfo.value.detail["requirement"]["target"] == "custom/acme" diff --git a/api/oss/tests/pytest/unit/gateways/test_gateways_ssrf_registration_gate.py b/api/oss/tests/pytest/unit/gateways/test_gateways_ssrf_registration_gate.py new file mode 100644 index 0000000000..c78755219c --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_gateways_ssrf_registration_gate.py @@ -0,0 +1,239 @@ +"""SSRF gate at registration (D28) — apis/fastapi/gateways/{llms,mcps}/router.py. + +`AGENTA_INSECURE_EGRESS_ALLOWED` defaults to `true` (`api/oss/src/utils/env.py`), so a +test that omits it passes while proving nothing. The module-level constant that actually +gates `validate_url_format_and_literal_ip` (`_WEBHOOK_ALLOW_INSECURE` in +`core/webhooks/utils.py`) is resolved once at import time, so setting the env var alone +has no effect on an already-imported process — every test here monkeypatches that +constant directly to `False`, mirroring `test_webhooks_utils.py`'s own technique. The +repo's autouse `secure_egress_by_default` fixture (`tests/pytest/utils/egress.py`) already +does this for the whole suite; this file pins it explicitly and locally so the SSRF +assertion does not depend on that other fixture being wired up. + +`custom` only — every endpoint this router can create/edit is custom by construction +(no way to express a builtin/agenta identity through these DTOs). +""" + +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from oss.src.apis.fastapi.gateways.llms.router import LLMGatewayRouter +from oss.src.apis.fastapi.gateways.mcps.router import MCPGatewayRouter +from oss.src.utils.context import AuthScope + +FIXED_SCOPE = AuthScope( + organization_id=uuid4(), + workspace_id=uuid4(), + project_id=uuid4(), + user_id=uuid4(), +) + + +@pytest.fixture(autouse=True) +def _secure_egress(monkeypatch): + """The load-bearing flag: patched directly (not via the env var — see module + docstring), explicitly `False` in every test in this file.""" + monkeypatch.setattr( + "oss.src.core.webhooks.utils._WEBHOOK_ALLOW_INSECURE", False, raising=False + ) + + +class _NullLlmService: + async def create_endpoint( + self, **_kwargs + ): # pragma: no cover - guarded before call + raise AssertionError("service must not be called when the SSRF gate rejects") + + edit_endpoint = create_endpoint + + +class _NullMcpService: + async def create_endpoint( + self, **_kwargs + ): # pragma: no cover - guarded before call + raise AssertionError("service must not be called when the SSRF gate rejects") + + edit_endpoint = create_endpoint + + +@pytest.fixture +def mcp_client(monkeypatch): + router = MCPGatewayRouter( + mcp_gateway_service=_NullMcpService(), oauth_connect_service=None + ) + app = FastAPI() + app.include_router(router.router) + monkeypatch.setattr( + "oss.src.apis.fastapi.gateways.mcps.router.get_auth_scope", + lambda: FIXED_SCOPE, + ) + monkeypatch.setattr( + "oss.src.apis.fastapi.gateways.mcps.router.check_action_access", + AsyncMock(return_value=True), + ) + return TestClient(app) + + +@pytest.fixture +def llm_client(monkeypatch): + router = LLMGatewayRouter(llm_gateway_service=_NullLlmService()) + app = FastAPI() + app.include_router(router.router) + monkeypatch.setattr( + "oss.src.apis.fastapi.gateways.llms.router.get_auth_scope", + lambda: FIXED_SCOPE, + ) + monkeypatch.setattr( + "oss.src.apis.fastapi.gateways.llms.router.check_action_access", + AsyncMock(return_value=True), + ) + return TestClient(app) + + +def _mcp_create_body(url: str) -> dict: + return { + "endpoint": { + "slug": "acme-mcp", + "auth_mode": "none", + "data": {"route": {"base_url": url}}, + } + } + + +# --------------------------------------------------------------------------- +# MCP: create — blocked targets 400 before the mock service is ever called +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "url", + [ + "http://169.254.169.254/mcp", # cloud metadata (link-local) + "http://127.0.0.1/mcp", # loopback + "http://10.0.0.1/mcp", # RFC-1918 private + "http://public.example.com/mcp", # plain http, secure mode requires https + ], +) +def test_mcp_create_endpoint_rejects_blocked_urls(mcp_client, url): + response = mcp_client.post("/endpoints/", json=_mcp_create_body(url)) + + assert response.status_code == 400 + assert "endpoint.data.route.base_url" in response.json()["detail"] + + +def test_mcp_create_endpoint_accepts_a_public_https_hostname_without_dns( + mcp_client, monkeypatch +): + """The whole point of the no-DNS variant: a public https hostname is accepted + without any resolution attempt. The mock service still 500s past this point + (it has none of the create fields the real one would validate further), but + that failure happens AFTER the gate — proving the gate itself let it through.""" + + def _fail_if_resolved(*_args, **_kwargs): + raise AssertionError("no DNS lookup should happen in the no-DNS variant") + + monkeypatch.setattr( + "oss.src.core.webhooks.utils.socket.getaddrinfo", _fail_if_resolved + ) + + response = mcp_client.post( + "/endpoints/", json=_mcp_create_body("https://mcp.public.example.com/notion") + ) + + # The gate passed (no 400, no DNS lookup); the stub service then raises, + # which intercept_exceptions turns into a 500 — not the gate's concern. + assert response.status_code == 500 + + +# --------------------------------------------------------------------------- +# MCP: edit — same gate, same blocked targets +# --------------------------------------------------------------------------- + + +def test_mcp_edit_endpoint_rejects_a_blocked_url(mcp_client): + endpoint_id = "00000000-0000-0000-0000-000000000001" + + response = mcp_client.put( + f"/endpoints/{endpoint_id}", + json={ + "endpoint": { + "id": endpoint_id, + "auth_mode": "none", + "data": {"route": {"base_url": "http://127.0.0.1/mcp"}}, + } + }, + ) + + assert response.status_code == 400 + assert "endpoint.data.route.base_url" in response.json()["detail"] + + +# --------------------------------------------------------------------------- +# LLM: base_url is optional — the gate is a no-op when it is absent, and +# applies identically to the blocked ranges when it is present. +# --------------------------------------------------------------------------- + + +def _llm_create_body(base_url) -> dict: + body = { + "endpoint": { + "slug": "acme-llm", + "provider_key": "openai", + "deployment_kind": "custom", + "data": {"route": {}}, + } + } + if base_url is not None: + body["endpoint"]["data"]["route"]["base_url"] = base_url + return body + + +@pytest.mark.parametrize( + "base_url", + [ + "http://169.254.169.254/v1", + "http://127.0.0.1/v1", + "http://10.0.0.1/v1", + "http://public.example.com/v1", + ], +) +def test_llm_create_endpoint_rejects_blocked_base_urls(llm_client, base_url): + response = llm_client.post("/endpoints/", json=_llm_create_body(base_url)) + + assert response.status_code == 400 + assert "endpoint.data.route.base_url" in response.json()["detail"] + + +def test_llm_create_endpoint_is_a_noop_when_base_url_absent(llm_client): + """No base_url set (e.g. a deployment_kind that never needed one) — the gate must + not reject a request that carries nothing for it to check.""" + response = llm_client.post("/endpoints/", json=_llm_create_body(None)) + + # Past the gate; the stub service raises next — proves the gate let it through. + assert response.status_code == 500 + + +# --------------------------------------------------------------------------- +# Negative control: the same private-IP body that 400s above must NOT 400 +# when the flag is (as it defaults) permissive. Proves the rejections above +# come from the flag being pinned False in this file, not a hardcoded 400. +# --------------------------------------------------------------------------- + + +def test_mcp_create_endpoint_accepts_the_same_private_url_when_insecure_allowed( + mcp_client, monkeypatch +): + monkeypatch.setattr( + "oss.src.core.webhooks.utils._WEBHOOK_ALLOW_INSECURE", True, raising=False + ) + + response = mcp_client.post( + "/endpoints/", json=_mcp_create_body("http://127.0.0.1/mcp") + ) + + # Gate let it through (no 400); the stub service raises next. + assert response.status_code == 500 diff --git a/api/oss/tests/pytest/unit/gateways/test_mock_adapters_contract.py b/api/oss/tests/pytest/unit/gateways/test_mock_adapters_contract.py new file mode 100644 index 0000000000..689ca1c0e3 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_mock_adapters_contract.py @@ -0,0 +1,177 @@ +"""Adapter interface contract tests (entities.md §7.1, workstreams/specs-wp5.md). + +The same fixture every `LLMUpstreamInterface` / `MCPUpstreamInterface` implementation +must pass — run against `MockLLMAdapter`/`MockMCPAdapter` now, reused by WP6/WP7/WP8/WP9's +real adapters once they exist. An adapter that is not implemented yet is parametrized in +as a skip, not omitted, so the moment `RelayLLMAdapter` etc. lands this file starts +enforcing the contract on it with no further edits here. + +Nothing running: plain Python objects. +""" + +import importlib +import json +from dataclasses import fields + +import httpx +import pytest + +from oss.src.core.gateways.llms.dtos import ( + LLMCallContext, + LLMDeploymentKind, + LLMResolvedRoute, +) +from oss.src.core.gateways.llms.interfaces import LLMRelayResult +from oss.src.core.gateways.llms.providers.mock.adapter import MockLLMAdapter + +from oss.src.core.gateways.mcps.dtos import ( + MCPCallContext, + MCPDirectAuth, + MCPResolvedRoute, +) +from oss.src.core.gateways.mcps.interfaces import MCPRelayResult +from oss.src.core.gateways.mcps.providers.mock.adapter import MockMCPAdapter + + +def _optional_instance(module_path: str, class_name: str): + """None when the module/class doesn't exist yet — the real adapters land + with WP6/WP7 (LLM) and WP8/WP9 (MCP), after this package.""" + try: + module = importlib.import_module(module_path) + return getattr(module, class_name)() + except (ImportError, AttributeError): + return None + + +def _adapter_param(instance, *, name: str): + if instance is None: + return pytest.param( + None, id=name, marks=pytest.mark.skip(reason=f"{name} not implemented yet") + ) + return pytest.param(instance, id=name) + + +def _passthrough_mock_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"id": "chatcmpl-contract", "choices": []}) + + +def _passthrough_llm_adapter(): + """RelayLLMAdapter needs real network I/O (entities.md §7.1), so — + unlike the other entries here — it cannot be built with a bare no-arg + constructor: it is wired against an `httpx.MockTransport` instead, keeping + this contract test in the "nothing running" tier (workstreams/specs-wp6.md).""" + try: + module = importlib.import_module( + "oss.src.core.gateways.llms.providers.passthrough.adapter" + ) + adapter_cls = module.RelayLLMAdapter + except (ImportError, AttributeError): + return None + + transport = httpx.MockTransport(_passthrough_mock_handler) + return adapter_cls(client=httpx.AsyncClient(transport=transport)) + + +def _mcp_mock_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": {}}) + + +def _http_mcp_adapter(): + """Like the passthrough one, HttpMCPAdapter makes a real outbound call, so it + gets an `httpx.MockTransport` rather than a bare constructor. + + It also runs the outbound guard (D28) before that transport is ever reached, so the + route below is a public https host with a patched resolver — what a real custom + server looks like — rather than an internal address the guard would rightly block.""" + try: + module = importlib.import_module( + "oss.src.core.gateways.mcps.providers.http.adapter" + ) + adapter_cls = module.HttpMCPAdapter + except (ImportError, AttributeError): + return None + + return adapter_cls(transport=httpx.MockTransport(_mcp_mock_handler)) + + +_LLM_ADAPTER_PARAMS = [ + _adapter_param(MockLLMAdapter(), name="MockLLMAdapter"), + _adapter_param(_passthrough_llm_adapter(), name="RelayLLMAdapter"), +] + +_MCP_ADAPTER_PARAMS = [ + _adapter_param(MockMCPAdapter(), name="MockMCPAdapter"), + _adapter_param(_http_mcp_adapter(), name="HttpMCPAdapter"), + _adapter_param( + _optional_instance( + "oss.src.core.gateways.mcps.providers.composio.adapter", + "ComposioMCPAdapter", + ), + name="ComposioMCPAdapter", + ), +] + + +@pytest.mark.parametrize("adapter", _LLM_ADAPTER_PARAMS) +async def test_relay_chat_completion_returns_llm_relay_result(adapter): + route = LLMResolvedRoute( + provider_key="mock", + deployment_kind=LLMDeploymentKind.DIRECT, + model="mock/echo", + # Unused by MockLLMAdapter; RelayLLMAdapter needs one to build an + # outbound URL, and the MockTransport above never dials it for real. + base_url="http://mock-passthrough-upstream.invalid", + ) + body = json.dumps( + {"model": "mock/echo", "messages": [{"role": "user", "content": "hi"}]} + ).encode() + + result = await adapter.relay_chat_completion( + route=route, + secret=None, + context=LLMCallContext(model="mock/echo"), + body=body, + headers={}, + ) + + assert isinstance(result, LLMRelayResult) + assert not isinstance(result, dict) + assert {f.name for f in fields(result)} == { + "status_code", + "headers", + "body", + "usage", + } + + +@pytest.mark.parametrize("adapter", _MCP_ADAPTER_PARAMS) +@pytest.mark.parametrize("method", ["initialize", "tools/list", "tools/call"]) +async def test_relay_returns_mcp_relay_result(adapter, method, monkeypatch): + # HttpMCPAdapter runs the outbound guard (D28) before its transport. Model a real + # custom server: a public https host, resolver patched so no DNS is needed. The + # guard stays on — a public target simply passes it, which is the whole point. + monkeypatch.setattr( + "oss.src.core.webhooks.utils._WEBHOOK_ALLOW_INSECURE", False, raising=False + ) + monkeypatch.setattr( + "oss.src.core.webhooks.utils.socket.getaddrinfo", + lambda *a, **kw: [(None, None, None, None, ("93.184.216.34", 0))], + ) + route = MCPResolvedRoute(url="https://mcp.example.com/") + auth = MCPDirectAuth(secret=None) + payload = {"jsonrpc": "2.0", "id": 1, "method": method} + if method == "tools/call": + payload["params"] = {"name": "echo", "arguments": {}} + body = json.dumps(payload).encode() + + result = await adapter.relay( + route=route, + auth=auth, + context=MCPCallContext(method=method), + body=body, + headers={}, + ) + + assert isinstance(result, MCPRelayResult) + assert not isinstance(result, dict) + assert {f.name for f in fields(result)} == {"status_code", "headers", "body"} diff --git a/api/oss/tests/pytest/unit/gateways/test_mock_llm_adapter.py b/api/oss/tests/pytest/unit/gateways/test_mock_llm_adapter.py new file mode 100644 index 0000000000..96bedd51df --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_mock_llm_adapter.py @@ -0,0 +1,215 @@ +"""Unit tests for MockLLMAdapter (entities.md §7.1, workstreams/specs-wp5.md). + +Nothing running: the adapter is exercised as a plain Python object. +""" + +import json +import time + +import pytest + +from oss.src.core.gateways.llms.dtos import ( + LLMCallContext, + LLMDeploymentKind, + LLMProtocol, + LLMResolvedRoute, +) +from oss.src.core.gateways.llms.interfaces import LLMRelayResult +from oss.src.core.gateways.llms.providers.mock.adapter import MockLLMAdapter +from oss.src.core.gateways.llms.types import LLMUpstreamError + + +def _route(model: str = "mock/echo") -> LLMResolvedRoute: + return LLMResolvedRoute( + provider_key="mock", deployment_kind=LLMDeploymentKind.DIRECT, model=model + ) + + +def _body(content: str = "hello") -> bytes: + return json.dumps( + {"model": "mock/echo", "messages": [{"role": "user", "content": content}]} + ).encode() + + +async def _drain(body): + return [chunk async for chunk in body] + + +@pytest.mark.asyncio +async def test_echo_returns_well_formed_result(): + adapter = MockLLMAdapter() + result = await adapter.relay_chat_completion( + route=_route(), + secret=None, + context=LLMCallContext(model="mock/echo"), + body=_body("hello there"), + headers={}, + ) + + assert isinstance(result, LLMRelayResult) + assert result.status_code == 200 + + chunks = await _drain(result.body) + assert len(chunks) == 1 + payload = json.loads(chunks[0]) + assert payload["choices"][0]["message"]["content"] == "hello there" + assert payload["object"] == "chat.completion" + + +@pytest.mark.asyncio +async def test_error_model_raises_upstream_error(): + adapter = MockLLMAdapter() + + with pytest.raises(LLMUpstreamError) as excinfo: + await adapter.relay_chat_completion( + route=_route("mock/error"), + secret=None, + context=LLMCallContext(model="mock/error"), + body=_body(), + headers={}, + ) + + assert excinfo.value.provider_key == "mock" + assert excinfo.value.status_code == 500 + + +@pytest.mark.asyncio +async def test_slow_model_sleeps_before_returning(): + adapter = MockLLMAdapter() + start = time.monotonic() + + result = await adapter.relay_chat_completion( + route=_route("mock/slow-1"), + secret=None, + context=LLMCallContext(model="mock/slow-1"), + body=_body(), + headers={}, + ) + elapsed = time.monotonic() - start + + assert elapsed >= 1 + assert result.status_code == 200 + await _drain(result.body) + + +@pytest.mark.asyncio +async def test_streaming_yields_multiple_chunks_ending_in_done(): + adapter = MockLLMAdapter() + + result = await adapter.relay_chat_completion( + route=_route(), + secret=None, + context=LLMCallContext(model="mock/echo", stream=True), + body=_body("hi"), + headers={}, + ) + + chunks = await _drain(result.body) + assert len(chunks) > 1 + assert chunks[-1] == b"data: [DONE]\n\n" + for chunk in chunks[:-1]: + assert chunk.startswith(b"data: ") + + +@pytest.mark.asyncio +async def test_usage_populated_after_body_exhausted(): + adapter = MockLLMAdapter() + + result = await adapter.relay_chat_completion( + route=_route(), + secret=None, + context=LLMCallContext(model="mock/echo"), + body=_body("hello world"), + headers={}, + ) + assert result.usage is None + + await _drain(result.body) + + assert result.usage is not None + assert result.usage.calls == 1 + assert result.usage.cost == 0.0 + assert result.usage.input_tokens is not None + assert result.usage.output_tokens is not None + + +# --- protocol-shaped bodies (D33, WP23) --------------------------------------- # + + +@pytest.mark.asyncio +async def test_responses_non_streaming_body_and_usage(): + adapter = MockLLMAdapter() + + result = await adapter.relay_chat_completion( + route=_route(), + secret=None, + context=LLMCallContext(model="mock/echo", protocol=LLMProtocol.RESPONSES), + body=_body("hello there"), + headers={}, + ) + + chunks = await _drain(result.body) + payload = json.loads(chunks[0]) + assert payload["object"] == "response" + assert payload["output"][0]["content"][0]["text"] == "hello there" + assert payload["usage"]["output_tokens"] == result.usage.output_tokens + + +@pytest.mark.asyncio +async def test_responses_streaming_ends_with_a_completed_frame_carrying_usage(): + adapter = MockLLMAdapter() + + result = await adapter.relay_chat_completion( + route=_route(), + secret=None, + context=LLMCallContext( + model="mock/echo", stream=True, protocol=LLMProtocol.RESPONSES + ), + body=_body("hi"), + headers={}, + ) + + chunks = await _drain(result.body) + assert chunks[-1].startswith(b"event: response.completed\n") + final = json.loads(chunks[-1].split(b"data: ", 1)[1]) + assert final["response"]["usage"]["output_tokens"] == result.usage.output_tokens + + +@pytest.mark.asyncio +async def test_messages_non_streaming_body_and_usage(): + adapter = MockLLMAdapter() + + result = await adapter.relay_chat_completion( + route=_route(), + secret=None, + context=LLMCallContext(model="mock/echo", protocol=LLMProtocol.MESSAGES), + body=_body("hello there"), + headers={}, + ) + + chunks = await _drain(result.body) + payload = json.loads(chunks[0]) + assert payload["type"] == "message" + assert payload["content"][0]["text"] == "hello there" + assert payload["usage"]["output_tokens"] == result.usage.output_tokens + + +@pytest.mark.asyncio +async def test_messages_streaming_ends_with_message_stop_and_usage_on_message_delta(): + adapter = MockLLMAdapter() + + result = await adapter.relay_chat_completion( + route=_route(), + secret=None, + context=LLMCallContext( + model="mock/echo", stream=True, protocol=LLMProtocol.MESSAGES + ), + body=_body("hi"), + headers={}, + ) + + chunks = await _drain(result.body) + assert chunks[-1].startswith(b"event: message_stop\n") + delta_frame = next(c for c in chunks if c.startswith(b"event: message_delta\n")) + payload = json.loads(delta_frame.split(b"data: ", 1)[1]) + assert payload["usage"]["output_tokens"] == result.usage.output_tokens diff --git a/api/oss/tests/pytest/unit/gateways/test_mock_mcp_adapter.py b/api/oss/tests/pytest/unit/gateways/test_mock_mcp_adapter.py new file mode 100644 index 0000000000..850b15dc64 --- /dev/null +++ b/api/oss/tests/pytest/unit/gateways/test_mock_mcp_adapter.py @@ -0,0 +1,137 @@ +"""Unit tests for MockMCPAdapter (entities.md §7.1, workstreams/specs-wp5.md). + +Nothing running: the adapter is exercised as a plain Python object. +""" + +import json +import time + +import pytest + +from oss.src.core.gateways.mcps.dtos import ( + MCPCallContext, + MCPDirectAuth, + MCPResolvedRoute, +) +from oss.src.core.gateways.mcps.interfaces import MCPRelayResult +from oss.src.core.gateways.mcps.providers.mock.adapter import MockMCPAdapter +from oss.src.core.gateways.mcps.types import MCPUpstreamError + + +def _route() -> MCPResolvedRoute: + return MCPResolvedRoute(url="http://mock-mcp-gateway:9092/") + + +def _auth() -> MCPDirectAuth: + return MCPDirectAuth(secret=None) + + +def _rpc(method: str, *, params=None, request_id=1) -> bytes: + payload = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + payload["params"] = params + return json.dumps(payload).encode() + + +@pytest.mark.asyncio +async def test_tools_list_returns_all_three_tools(): + adapter = MockMCPAdapter() + + result = await adapter.relay( + route=_route(), + auth=_auth(), + context=MCPCallContext(method="tools/list"), + body=_rpc("tools/list"), + headers={}, + ) + + assert isinstance(result, MCPRelayResult) + payload = json.loads(result.body) + names = {tool["name"] for tool in payload["result"]["tools"]} + assert names == {"echo", "fail", "slow"} + + +@pytest.mark.asyncio +async def test_echo_tool_echoes_arguments(): + adapter = MockMCPAdapter() + + result = await adapter.relay( + route=_route(), + auth=_auth(), + context=MCPCallContext(method="tools/call"), + body=_rpc("tools/call", params={"name": "echo", "arguments": {"x": 1}}), + headers={}, + ) + + payload = json.loads(result.body) + content = payload["result"]["content"][0]["text"] + assert json.loads(content) == {"x": 1} + assert payload["result"]["isError"] is False + + +@pytest.mark.asyncio +async def test_fail_tool_returns_error_result_not_exception(): + adapter = MockMCPAdapter() + + result = await adapter.relay( + route=_route(), + auth=_auth(), + context=MCPCallContext(method="tools/call"), + body=_rpc("tools/call", params={"name": "fail"}), + headers={}, + ) + + assert result.status_code == 200 + payload = json.loads(result.body) + assert payload["result"]["isError"] is True + + +@pytest.mark.asyncio +async def test_slow_tool_sleeps(): + adapter = MockMCPAdapter() + start = time.monotonic() + + result = await adapter.relay( + route=_route(), + auth=_auth(), + context=MCPCallContext(method="tools/call"), + body=_rpc("tools/call", params={"name": "slow", "arguments": {"seconds": 1}}), + headers={}, + ) + elapsed = time.monotonic() - start + + assert elapsed >= 1 + payload = json.loads(result.body) + assert payload["result"]["isError"] is False + + +@pytest.mark.asyncio +async def test_unrecognized_method_raises_upstream_error(): + adapter = MockMCPAdapter() + + with pytest.raises(MCPUpstreamError) as excinfo: + await adapter.relay( + route=_route(), + auth=_auth(), + context=MCPCallContext(method="resources/list"), + body=_rpc("resources/list"), + headers={}, + ) + + assert excinfo.value.status_code == 501 + + +@pytest.mark.asyncio +async def test_notification_returns_202_with_empty_body(): + adapter = MockMCPAdapter() + + result = await adapter.relay( + route=_route(), + auth=_auth(), + context=MCPCallContext(method="notifications/initialized"), + body=_rpc("notifications/initialized"), + headers={}, + ) + + assert result.status_code == 202 + assert result.body == b"" diff --git a/api/oss/tests/pytest/unit/middlewares/test_credentials_header.py b/api/oss/tests/pytest/unit/middlewares/test_credentials_header.py new file mode 100644 index 0000000000..9da95ea701 --- /dev/null +++ b/api/oss/tests/pytest/unit/middlewares/test_credentials_header.py @@ -0,0 +1,91 @@ +"""`X-AG-Credentials` is the gateways' inbound credentials header (D31). + +It exists because on a subscription pass-through route `Authorization` carries the +caller's own vendor auth, which is not ours to read — so a fallback ordering would +read the wrong header exactly where the header was introduced to help. +""" + +from starlette.requests import Request + +from oss.src.middlewares.auth import _credentials_header + + +def _request(headers: dict, path: str = "/") -> Request: + raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()] + return Request({"type": "http", "method": "GET", "path": path, "headers": raw}) + + +_DATA_PLANE = "/gateways/llms/custom/acme/v1/chat/completions" +_CRUD = "/gateways/llms/endpoints/" + + +def test_authorization_alone_is_still_read(): + assert _credentials_header(_request({"Authorization": "ApiKey k"})) == "ApiKey k" + + +def test_credentials_header_alone_is_read(): + assert ( + _credentials_header(_request({"X-AG-Credentials": "Secret jwt"})) + == "Secret jwt" + ) + + +def test_credentials_header_wins_when_both_are_present(): + request = _request( + { + "Authorization": "Bearer vendor-subscription", + "X-AG-Credentials": "Secret jwt", + } + ) + + assert _credentials_header(request) == "Secret jwt" + + +def test_neither_header_yields_none(): + assert _credentials_header(_request({})) is None + + +def test_header_lookup_is_case_insensitive(): + assert _credentials_header(_request({"x-ag-credentials": "Secret jwt"})) == ( + "Secret jwt" + ) + + +# --- the data plane reads our header and nothing else (D31) ------------------ # + + +def test_data_plane_ignores_authorization_entirely(): + """There `Authorization` is the caller's own vendor auth, bound for the upstream — + reading it as ours is how a subscription token gets mistaken for a gateway token.""" + request = _request({"Authorization": "ApiKey k"}, path=_DATA_PLANE) + + assert _credentials_header(request) is None + + +def test_data_plane_still_reads_our_header(): + request = _request({"X-AG-Credentials": "Secret jwt"}, path=_DATA_PLANE) + + assert _credentials_header(request) == "Secret jwt" + + +def test_the_crud_routes_under_the_same_prefix_keep_the_fallback(): + """`/gateways/{plane}/endpoints/...` is ordinary CRUD; no namespace spells + `endpoints`, which is what keeps the two apart under one mount prefix.""" + request = _request({"Authorization": "ApiKey k"}, path=_CRUD) + + assert _credentials_header(request) == "ApiKey k" + + +def test_the_mcp_data_plane_is_covered_too(): + request = _request({"Authorization": "ApiKey k"}, path="/gateways/mcps/custom/acme") + + assert _credentials_header(request) is None + + +def test_the_api_prefixed_data_plane_is_covered_too(): + request = _request( + {"Authorization": "ApiKey k"}, + path="/api/gateways/llms/standard/openai/v1/models", + ) + + assert _credentials_header(request) is None diff --git a/api/oss/tests/pytest/unit/secrets/test_dtos.py b/api/oss/tests/pytest/unit/secrets/test_dtos.py index ecef0c90ab..11a0517b22 100644 --- a/api/oss/tests/pytest/unit/secrets/test_dtos.py +++ b/api/oss/tests/pytest/unit/secrets/test_dtos.py @@ -7,11 +7,13 @@ CreateSecretDTO, CustomProviderDTO, CustomSecretDTO, + OAuthGrantDTO, + OAuthProviderDTO, SecretResponseDTO, StandardProviderDTO, UpdateSecretDTO, ) -from oss.src.core.secrets.enums import StandardProviderKind +from oss.src.core.secrets.enums import SecretKind, StandardProviderKind def test_create_secret_normalizes_mistralai_standard_provider_payload(): @@ -330,6 +332,33 @@ def _payload_without_a_header(kind): "data": {"provider": {"key": "whsec"}}, }, }, + "oauth_provider": { + "header": {}, + "secret": { + "kind": "oauth_provider", + "data": { + "provider": { + "client_id": "id", + "client_secret": "secret", + "issuer_url": "https://issuer.example", + "scopes": ["openid"], + } + }, + }, + }, + "oauth_grant": { + "header": {}, + "secret": { + "kind": "oauth_grant", + "data": { + "grant": { + "server": "https://issuer.example", + "access_token": "at-123", + "scopes": ["openid"], + } + }, + }, + }, } payload = payloads[kind] payload["header"] = {} @@ -337,7 +366,15 @@ def _payload_without_a_header(kind): @pytest.mark.parametrize( - "kind", ["custom_provider", "custom_secret", "sso_provider", "webhook_provider"] + "kind", + [ + "custom_provider", + "custom_secret", + "sso_provider", + "webhook_provider", + "oauth_provider", + "oauth_grant", + ], ) def test_create_secret_rejects_an_empty_header(kind): with pytest.raises(ValidationError, match="Header cannot be empty"): @@ -357,3 +394,111 @@ def test_create_secret_allows_an_empty_header_for_a_provider_key(): ) assert secret.header.name is None + + +def _oauth_provider_payload(**provider): + return { + "header": {"name": "GitHub OAuth", "description": ""}, + "secret": { + "kind": "oauth_provider", + "data": { + "provider": { + "client_id": "id", + "client_secret": "secret", + "issuer_url": "https://issuer.example", + "scopes": ["openid"], + **provider, + } + }, + }, + } + + +def test_create_oauth_provider_secret(): + secret = CreateSecretDTO.model_validate(_oauth_provider_payload()) + + assert isinstance(secret.secret.data, OAuthProviderDTO) + assert secret.secret.data.provider.client_id == "id" + assert secret.secret.data.provider.issuer_url == "https://issuer.example" + assert secret.secret.data.provider.scopes == ["openid"] + + +def test_oauth_provider_kind_decides_shape_though_sso_provider_shares_it(): + # OAuthProviderDTO and SSOProviderDTO have an identical shape; the kind, not the union, + # must decide which class the data resolves to. + secret = CreateSecretDTO.model_validate(_oauth_provider_payload()) + + assert type(secret.secret.data) is OAuthProviderDTO + + +@pytest.mark.parametrize( + "missing", ["client_id", "client_secret", "issuer_url", "scopes"] +) +def test_create_oauth_provider_rejects_missing_field(missing): + payload = _oauth_provider_payload() + del payload["secret"]["data"]["provider"][missing] + + with pytest.raises(ValidationError, match="OAuthProviderSettingsDTO"): + CreateSecretDTO.model_validate(payload) + + +def _oauth_grant_payload(**grant): + return { + "header": {"name": "GitHub grant", "description": ""}, + "secret": { + "kind": "oauth_grant", + "data": { + "grant": { + "server": "https://issuer.example", + "access_token": "at-123", + "scopes": ["openid"], + **grant, + } + }, + }, + } + + +def test_create_oauth_grant_secret(): + secret = CreateSecretDTO.model_validate( + _oauth_grant_payload(refresh_token="rt-456", expires_at=1893456000) + ) + + assert isinstance(secret.secret.data, OAuthGrantDTO) + assert secret.secret.data.grant.server == "https://issuer.example" + assert secret.secret.data.grant.access_token == "at-123" + assert secret.secret.data.grant.refresh_token == "rt-456" + assert secret.secret.data.grant.expires_at == 1893456000 + + +def test_create_oauth_grant_without_refresh_token_defaults_to_none(): + secret = CreateSecretDTO.model_validate(_oauth_grant_payload()) + + assert secret.secret.data.grant.refresh_token is None + assert secret.secret.data.grant.expires_at is None + + +@pytest.mark.parametrize("missing", ["server", "access_token", "scopes"]) +def test_create_oauth_grant_rejects_missing_field(missing): + payload = _oauth_grant_payload() + del payload["secret"]["data"]["grant"][missing] + + with pytest.raises(ValidationError, match="OAuthGrantSettingsDTO"): + CreateSecretDTO.model_validate(payload) + + +def test_secret_kind_enum_keeps_existing_members_appended_only(): + # Regression guard: new kinds must append, never renumber or reorder the existing set, + # so parallel work adding kinds to this same enum merges cleanly. + expected_prefix = [ + "provider_key", + "custom_provider", + "sso_provider", + "webhook_provider", + "custom_secret", + ] + actual_prefix = [member.value for member in SecretKind][: len(expected_prefix)] + + assert actual_prefix == expected_prefix + assert "oauth_provider" in [member.value for member in SecretKind] + assert "oauth_grant" in [member.value for member in SecretKind] diff --git a/api/oss/tests/pytest/unit/webhooks/test_webhooks_utils.py b/api/oss/tests/pytest/unit/webhooks/test_webhooks_utils.py index cf850b409e..fcd1f388dd 100644 --- a/api/oss/tests/pytest/unit/webhooks/test_webhooks_utils.py +++ b/api/oss/tests/pytest/unit/webhooks/test_webhooks_utils.py @@ -224,3 +224,34 @@ def test_allow_insecure_canonical_wins_over_legacy_alias(monkeypatch): monkeypatch.delenv("AGENTA_INSECURE_EGRESS_ALLOWED", raising=False) monkeypatch.delenv("AGENTA_WEBHOOKS_ALLOW_INSECURE", raising=False) importlib.reload(env) + + +# --------------------------------------------------------------------------- +# End-to-end: the env var itself, not a monkeypatched module constant, drives +# the refusal. Every other SSRF test in this repo pins `_WEBHOOK_ALLOW_INSECURE` +# directly (see `tests/pytest/utils/egress.py`), which proves the guard's logic +# but not that AGENTA_INSECURE_EGRESS_ALLOWED=false — the value every shared +# deployment now sets — actually reaches it. +# --------------------------------------------------------------------------- + + +def test_insecure_egress_allowed_false_blocks_private_address_end_to_end(monkeypatch): + from oss.src.utils import env + + monkeypatch.setenv("AGENTA_INSECURE_EGRESS_ALLOWED", "false") + monkeypatch.delenv("AGENTA_WEBHOOKS_ALLOW_INSECURE", raising=False) + monkeypatch.delenv("AGENTA_WEBHOOK_ALLOW_INSECURE", raising=False) + try: + importlib.reload(env) + from oss.src.core.webhooks import utils as webhook_utils + + importlib.reload(webhook_utils) + assert webhook_utils._WEBHOOK_ALLOW_INSECURE is False + with pytest.raises(ValueError, match="blocked IP"): + webhook_utils.resolve_validated_webhook_ip("https://10.0.0.5/hook") + finally: + monkeypatch.delenv("AGENTA_INSECURE_EGRESS_ALLOWED", raising=False) + importlib.reload(env) + from oss.src.core.webhooks import utils as webhook_utils + + importlib.reload(webhook_utils) diff --git a/api/oss/tests/pytest/unit/workflows/test_static_catalog.py b/api/oss/tests/pytest/unit/workflows/test_static_catalog.py index 0816eede00..77ca8c00ec 100644 --- a/api/oss/tests/pytest/unit/workflows/test_static_catalog.py +++ b/api/oss/tests/pytest/unit/workflows/test_static_catalog.py @@ -1005,3 +1005,64 @@ def test_request_input_matches_golden_response_fixture(): assert golden["degradation_error_text"].startswith( "elicitation: unsupported payload — " ) + + +# --------------------------------------------------------------------------- +# WP26 — request_connection widened for gateway targets +# --------------------------------------------------------------------------- + + +def _request_connection_tool() -> dict: + from agenta.sdk.agents.platform.workflow import REQUEST_CONNECTION_WORKFLOW_SLUG + + revision = StaticWorkflowCatalog().retrieve_revision( + slug=REQUEST_CONNECTION_WORKFLOW_SLUG + ) + assert revision is not None + return revision.data.parameters["tool"] + + +def test_request_connection_schema_accepts_integration_or_target(): + """Neither `integration` nor `target` is individually required any more — a call may use + either path. The `mode`/`slug` fields (integration-only) are unchanged in shape.""" + tool = _request_connection_tool() + schema = tool["input_schema"] + assert set(schema["properties"]) == {"integration", "target", "slug", "mode"} + assert schema["required"] == [] + assert schema["additionalProperties"] is False + assert schema["properties"]["mode"]["enum"] == ["oauth", "api_key"] + + +def test_request_connection_target_shape(): + """The new `target` object discriminates by `plane` and names the provider/server.""" + tool = _request_connection_tool() + target = tool["input_schema"]["properties"]["target"] + assert target["type"] == "object" + assert target["required"] == ["plane", "name"] + assert target["additionalProperties"] is False + assert target["properties"]["plane"]["enum"] == ["llm", "mcp"] + assert target["properties"]["name"]["type"] == "string" + + +@pytest.mark.parametrize( + "call", + [ + {"integration": "slack"}, + {"target": {"plane": "llm", "name": "openai"}}, + {"target": {"plane": "mcp", "name": "acme-notion"}}, + ], +) +def test_request_connection_coerces_to_client_tool_config_for_both_paths(call): + """Both the existing integration path and the new gateway-target path are valid CALLS of + the same tool config — this asserts the tool config itself (unchanged shape: type, + render), not the call, since the call is model-authored input, not part of the config.""" + from agenta.sdk.agents.tools.compat import coerce_tool_config + from agenta.sdk.agents.tools.models import ClientToolConfig + + tool = _request_connection_tool() + coerced = coerce_tool_config(tool) + assert isinstance(coerced, ClientToolConfig) + assert coerced.render == {"kind": "connect"} + assert coerced.name == "request_connection" + # The call itself must validate against the schema's declared properties. + assert set(call) <= set(tool["input_schema"]["properties"]) diff --git a/api/oss/tests/pytest/utils/postgres.py b/api/oss/tests/pytest/utils/postgres.py new file mode 100644 index 0000000000..8c40f02b03 --- /dev/null +++ b/api/oss/tests/pytest/utils/postgres.py @@ -0,0 +1,73 @@ +"""Where the integration layer's Postgres actually is, from wherever pytest is running. + +The application default names the compose host (`postgres:5432`), which is correct for +every container and unreachable from the host that started them. Compose publishes the +same server on loopback, so a host-side run reaches it without the deployment's env file +having to be edited — the containers read that same file and need the service name. + +The check opens a real connection rather than a TCP probe: reaching the port proves a +server is listening, not that this deployment's database is there. A stack deployed under +a different `AGENTA_LICENSE` listens on the same port with different database names, and +a TCP probe would send the suite into it and fail every test instead of skipping. +""" + +import asyncio +from functools import lru_cache +from typing import Optional +from urllib.parse import urlparse, urlunparse + +from oss.src.utils.env import env + + +async def _can_connect(dsn: str) -> bool: + import asyncpg + + try: + connection = await asyncio.wait_for(asyncpg.connect(dsn), timeout=2.0) + except (OSError, asyncio.TimeoutError, asyncpg.PostgresError): + return False + + await connection.close() + return True + + +def _connectable(uri: str) -> bool: + dsn = uri.replace("postgresql+asyncpg://", "postgresql://") + try: + return asyncio.run(_can_connect(dsn)) + except RuntimeError: # already inside a loop; assume usable and let the test say + return True + + +def _on_loopback(uri: str) -> str: + parsed = urlparse(uri) + userinfo = parsed.netloc.rsplit("@", 1)[0] if "@" in parsed.netloc else "" + host = f"127.0.0.1:{parsed.port or 5432}" + return urlunparse( + parsed._replace(netloc=f"{userinfo}@{host}" if userinfo else host) + ) + + +@lru_cache(maxsize=1) +def resolve_core_uri() -> Optional[str]: + """The core URI as configured, else the same database via published loopback. + + None means this deployment's Postgres is not reachable and the caller should skip. + """ + uri = env.postgres.uri_core + if _connectable(uri): + return uri + + loopback = _on_loopback(uri) + if loopback != uri and _connectable(loopback): + return loopback + + return None + + +def use_reachable_core_uri() -> Optional[str]: + """Point the shared `env` at the resolved URI so engines built later use it.""" + resolved = resolve_core_uri() + if resolved is not None: + env.postgres.uri_core = resolved + return resolved diff --git a/api/pyproject.toml b/api/pyproject.toml index 1e3efd5512..aa9bb3c567 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "uvicorn[standard]>=0.51,<0.52", "gunicorn>=26,<27", "httpx>=0.28,<0.29", + "mcp>=1.21,<2", "structlog>=26,<27", "python-dotenv>=1,<2", "python-multipart>=0.0.32,<0.0.33", @@ -35,6 +36,7 @@ dependencies = [ "cachetools>=7,<8", "supertokens-python>=0.31,<0.32", "openai>=2,<3", + "litellm>=1.92,<2", "sendgrid>=6,<7", "stripe>=15,<16", "posthog>=7,<8", diff --git a/api/uv.lock b/api/uv.lock index 29fb3c47ec..b4ba26298e 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -292,6 +292,8 @@ dependencies = [ { name = "gunicorn" }, { name = "httpx" }, { name = "jsonschema" }, + { name = "litellm" }, + { name = "mcp" }, { name = "miniopy-async" }, { name = "newrelic" }, { name = "openai" }, @@ -347,6 +349,8 @@ requires-dist = [ { name = "gunicorn", specifier = ">=26,<27" }, { name = "httpx", specifier = ">=0.28,<0.29" }, { name = "jsonschema", specifier = ">=4,<5" }, + { name = "litellm", specifier = ">=1.92,<2" }, + { name = "mcp", specifier = ">=1.21,<2" }, { name = "miniopy-async", specifier = ">=1,<2" }, { name = "newrelic", specifier = ">=13,<14" }, { name = "openai", specifier = ">=2,<3" }, @@ -1181,6 +1185,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "httpx-ws" version = "0.9.0" @@ -1451,6 +1464,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, ] +[[package]] +name = "mcp" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" }, +] + [[package]] name = "miniopy-async" version = "1.21.3" @@ -2130,6 +2168,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -2290,6 +2342,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2625,6 +2693,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/25/7400c18c3ee97914cc99c90007795c00a4ec5b60c853b49db7ba24d11179/sqlalchemy_utils-0.42.1-py3-none-any.whl", hash = "sha256:243cfe1b3a1dae3c74118ae633f1d1e0ed8c787387bc33e556e37c990594ac80", size = 91761, upload-time = "2025-12-13T03:14:15.014Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/00/b42a44342a054d58cb1115d7c8aa9cb4290dd9442f9c1b91a4b8173dba22/sse_starlette-3.4.8.tar.gz", hash = "sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c", size = 32548, upload-time = "2026-08-05T11:19:49.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/3a/764912c58293d95b6dcdf4cc255f9d10de310580ced547b082eb9d72018c/sse_starlette-3.4.8-py3-none-any.whl", hash = "sha256:6e82314c786709a3cd9520f2285cf9fff90e181e598e8a357b0cf80f66afba0d", size = 16516, upload-time = "2026-08-05T11:19:48.748Z" }, +] + [[package]] name = "starlette" version = "1.3.1" diff --git a/docs/design/dev-ingress/research.md b/docs/design/dev-ingress/research.md new file mode 100644 index 0000000000..fef5354fe2 --- /dev/null +++ b/docs/design/dev-ingress/research.md @@ -0,0 +1,113 @@ +# The development ingress — research + +What already exists, checked against the code and the deployment repository rather +than inferred. Two claims in this project's first drafts were argued from what a +value looked like and both were wrong, so every statement here names its source. + +## The distinction that decides everything: who has to reach us + +| direction | example | needs a public address? | +| --- | --- | --- | +| a provider calls us | Slack posts an event or a button click | **yes** | +| an authorization server fetches a document from us | the newer OAuth client-registration mechanism reads a client-identity URL | **yes** | +| a browser is sent back to us | any OAuth redirect after consent | **no** | + +The third row is the one people assume needs a tunnel. It does not. A redirect is a +browser navigation: the user is already looking at our interface, so whatever address +got them there is an address their browser reaches, and the authorization server never +fetches it. + +**The gateways research reached this independently and closed it** — its `D26` and +`OD6` say the redirect needs nothing built, and they are right. So this work is an +**inbound-delivery** change. OAuth benefits only in the one case where the +authorization server itself fetches something from us. + +## Three patterns already carry inbound events in development + +None of them is reusable for an arbitrary provider, and each is worth knowing so +nobody proposes it again. + +| what | mechanism | public URL? | +| --- | --- | --- | +| Stripe | the vendor CLI: `listen --forward-to http://api:8000/billing/stripe/events/` | no | +| Composio | `dispatcher_composio.py` subscribes over Composio's **own** WebSocket and forwards to the local ingress, HMAC-signed with the real secret so the true signature path runs | no | +| ngrok | a real tunnel — pointed at `seaweedfs:8333`, for remote sandbox mounts | yes, but only for the store | + +The tunnel service is addressed by its compose name: the runner's compiled-in default is +`http://:4040` and nothing overrides it, so the service name is load-bearing. +That port is the tunnel **agent's** own admin API, which lists what it publishes — the +runner queries it to learn the store's public address. Only the store is discovered this +way; nothing in the code ever asks for an ingress address. + +The Composio dispatcher describes itself as the `stripe listen` equivalent. Both +depend on the provider offering a subscribe call. **An arbitrary platform offers +nothing equivalent**, which is why a tunnel is the only general answer. + +Stripe's CLI runs in production too. Composio in production uses the real public +route. So both patterns are live, and a public inbound route is already normal. + +## What production actually does + +From the deployment repository, which is **not in this tree** and is the authority on +public URLs. + +- **One public host**, `TRAEFIK_DOMAIN`, with an optional alias. `/` web, `/api/` api, + `/services/` services, `/m` mobile. TLS per host. +- **`/api/` is routed and stripped**, and the api service sets `SCRIPT_NAME=/api`. +- **No `seaweedfs`, no `ngrok`, no Composio dispatcher.** The store is real S3. + +So the tunnel is a development stand-in for a domain. Production needs none: the store +is already public and the platform is already on a domain. + +## The API already composes its own public URLs correctly + +`entrypoints/routers.py` creates the app with `root_path="/api"`, unconditionally, and +Starlette's `Request.base_url` builds its path from `app_root_path`. So +`request.base_url` is `:///api/`, which matches the public shape +production serves. + +**Consequence:** anything the API hands out — a platform manifest, a redirect address, +a server URL — is correct with no configuration, *provided the request arrived on the +public host*. That is the whole argument for a reserved domain over a rotating one: +addresses we give a provider are registered on their side, once. + +## The store cannot share one host by path + +S3 SigV4 signs the canonical request path, so a prefix a proxy strips invalidates +every request. The self-host compose file already records this in its own words — its +store router is `Host`-only, *"which SeaweedFS S3 SigV4 requires"* — and publishes the +store on its own subdomain with `AGENTA_STORE_TRAEFIK_ENABLE` and +`AGENTA_STORE_DOMAIN`. + +So: subpaths within the platform host, a separate host for the store. Production uses +each where it belongs. + +## The store bucket is configuration, not a constant + +`AGENTA_STORE_BUCKET`, and the defaults disagree: the API falls back to +`agenta-store`; the self-host compose passes the API an empty default while giving +SeaweedFS `agenta-store`. `AGENTA_STORE_NAMESPACE` exists as well. Any routing rule +written against a literal bucket name is wrong by construction. + +## The tunnel is already conditional + +`environment.ts` consults the tunnel only when `storeReachableFromSandbox()` is false, +and that returns false only for a bare compose service name, `localhost`, `.local`, or +a private range. A public store hostname means the tunnel is never asked for. + +## The defect this uncovered + +`discoverTunnelEndpoint` returns **the first https tunnel**, with no check on what it +forwards to: + +```ts +const https = tunnels.find((t) => t.proto === "https" && !!t.public_url)?.public_url; +``` + +Correct only while exactly one tunnel exists and it happens to be the store's. Add a +second and the runner may hand a sandbox the platform's HTTP API as an object store — +a failure far from its cause. + +**This stops being hypothetical the moment a second tunnel exists**, which is what this +work adds. A third is already designed: the remote-tools-delivery specs propose reusing +this same infrastructure to publish an MCP server URL into a sandbox. diff --git a/docs/design/dev-ingress/specs.md b/docs/design/dev-ingress/specs.md new file mode 100644 index 0000000000..7f60578656 --- /dev/null +++ b/docs/design/dev-ingress/specs.md @@ -0,0 +1,132 @@ +# The development ingress — specs + +**Add** a second development tunnel that publishes this deployment's ingress, beside the +one that publishes the object store. Each service is named for what it publishes: +`ngrok-mounts` and `ngrok-ingress`. The store tunnel keeps its behaviour; only its name moves. + +Research: [research.md](research.md). Tasks: [tasks.md](tasks.md). + +## One package, and why there is no breakdown + +**No work packages.** Two edits that only make sense together: the new tunnel, and the +selector that stops the runner picking a tunnel by list order. Landing the tunnel +without the selector is the one combination that breaks something — the runner would +find two tunnels and might hand a sandbox the platform's HTTP API as an object store. + +It is also small: two compose files, two env examples, one function, one call site, +three tests. + +## What it is for + +Anything outside that has to reach this deployment: a platform posting a webhook, or an +authorization server fetching a document we serve. + +**Not OAuth redirects.** Those are browser navigations and need nothing — the gateways +research settled that in its `D26`, and it is right (`research.md`). + +Three consumers are already known: channels needs platform events; the model and MCP +gateways need the client-identity fetch; the remote-tools work wants to publish a server +URL into a sandbox. + +## The target + +### 1. Two tunnels, named for what they publish + +`ngrok` becomes **`ngrok-mounts`** — it publishes the object store, and its name should say +so. A new **`ngrok-ingress`** service publishes the ingress, forwarding to `traefik:80`. +Same `with-tunnel` profile, same token gate, same quiet exit-0 when no token is set. +`NGROK_DOMAIN_INGRESS` pins a reserved domain. + +**The store tunnel keeps its behaviour exactly.** Same target, same token gate, same +comments — only the service name changes, and with it the one place that addresses it. +Daytona sandboxes with the bundled store keep their durable working folder. + +**The rename has one consequence.** The runner learns the store's public address at run +time, by asking the tunnel daemon over its local admin port. That address is the compose +service name, so it moves with the rename: `http://ngrok:4040` becomes +`http://ngrok-mounts:4040`. An operator with a stack already up also has an orphaned +`ngrok` container, which `--remove-orphans` clears. + +**`AGENTA_MOUNTS_TUNNEL_API` is removed rather than renamed.** It overrode that same +address and was never set — not in either edition's compose, not in either env example, not +in a deployment. Tests inject through the `deps` seam instead, and the service name already +resolves, so it was configuration with no consumer. The seam stays; re-adding an override is +one line beside the default if a daemon ever runs outside compose. + +**Only one variable, and it is for a person.** `NGROK_DOMAIN_INGRESS` pins a reserved +domain. Nothing in the code reads the ingress tunnel's address — it is read off the daemon's +dashboard once and registered with a provider by hand — so the variable's whole job is to +keep that registration valid across a restart. + +**Every inbound route arrives on its normal path.** `/api/` is already routed in +development, in the self-host compose files, and in production, so no integration needs +a tunnel or a route of its own: + +```text +/api/channels/slack/events/ a platform event or interaction +/api/triggers/composio/events/ already the production path +/api/billing/stripe/events/ today served by the vendor CLI instead +/api//... whatever comes next +``` + +**Do not add a tunnel per integration.** One endpoint serves all of them. + +### 2. Why two services rather than one agent with two endpoints + +Two separate services keep the store tunnel's invocation as it is today, which is the +form already proven in this repo, and keep the runner's agent address pointing at an +agent that only ever lists the store. Neither tunnel can be handed the +other's URL. + +**The cost is two simultaneous agent sessions.** If the tunnel plan allows only one, the +fallback is a single agent with two named endpoints — and the selector below makes that +arrangement safe. Both compose files say so where an operator will read it. + +### 3. The selector matches on upstream, not on order + +`discoverTunnelEndpoint` returned **the first https tunnel**, with no check on what it +forwarded to. That was correct only while exactly one tunnel existed. This work adds a +second, so it stops being correct here. + +It now takes the store endpoint it is looking for and accepts only a tunnel whose own +upstream is that endpoint, compared on host and port so the agent's spelling does not +matter. **No fallback to the first tunnel when a store endpoint is given** — a wrong +endpoint is worse than none, because none is already handled: the caller refuses the +mount and says so, to the operator through a warning and to the model through its +guidance. With no store endpoint supplied the old behaviour stands, so nothing else that +calls it moves. + +With two separate agents this is belt and braces. It is still the fix that makes a +second tunnel safe at all, and the one that makes the single-agent fallback possible. + +## What it costs + +**The development API becomes publicly reachable** when the tunnel is up. Everything +Traefik serves is exposed to whoever has the address, including the web interface. +Channel ingress verifies signatures; not every route does. Development-only, off without +a token, and still a real change in exposure. + +**Two agent sessions**, as above. + +**A tunnel provider may interpose a browser interstitial** on free plans for HTML +responses. It does not affect a provider posting to us. Verify against the plan in use +before relying on a browser path through the tunnel. + +## What it does not change + +- **What the store tunnel does.** Same target, same gate, same comments — the name + changes and the runner's default address follows it. Nothing else. +- **Nothing in Traefik.** `/api/` already routes, in every compose file. +- **Nothing in production.** No tunnel exists there and none is added. +- **No API code, no new routes, no new configuration the API reads.** +- **The vendor-CLI patterns.** Stripe keeps its CLI; Composio keeps its socket in + development and the real route in production. + +## Done when + +- A provider on the internet can post to `/api/...` on this deployment. +- A Daytona sandbox with the bundled store still mounts its durable folder — the + regression check that matters most. +- With no token set, neither tunnel publishes anything and neither service loops. +- A sandbox mount gets the store's own tunnel, never the ingress one. +- The two branches that need this can merge it without conflict. diff --git a/docs/design/dev-ingress/tasks.md b/docs/design/dev-ingress/tasks.md new file mode 100644 index 0000000000..567d48c11e --- /dev/null +++ b/docs/design/dev-ingress/tasks.md @@ -0,0 +1,80 @@ +# The development ingress — tasks + +Specs: [specs.md](specs.md). Research: [research.md](research.md). + +Branch `chore/dev-ingress-tunnel`, branched from `main`. One package, no breakdown — +see `specs.md` for why. + +## Code — done + +- [x] New `ngrok-ingress` service in `hosting/docker-compose/oss/docker-compose.dev.yml` + and the EE twin, forwarding to `traefik:80`, on the `with-tunnel` profile, gated + on `NGROK_AUTHTOKEN`, with `NGROK_DOMAIN_INGRESS` optional. +- [x] `ngrok` renamed to **`ngrok-mounts`**, so each service is named for what it + publishes. Its target, token gate, comments and `depends_on` are otherwise + unchanged from `main`. +- [x] The runner's compiled-in daemon address follows the rename: + `http://ngrok:4040` becomes `http://ngrok-mounts:4040`. +- [x] `AGENTA_MOUNTS_TUNNEL_API` removed. It overrode that address and was never set + anywhere; tests inject through the `deps` seam, which stays. +- [x] `discoverTunnelEndpoint` takes `storeEndpoint` and matches a tunnel by its + upstream host and port; returns null when none matches rather than another + tunnel's URL. +- [x] Both call sites in `environment.ts` pass the store endpoint they already hold. +- [x] Three tests: the two-tunnel case picks the store's; no matching tunnel returns + null; the upstream matches however the agent spells it. +- [x] `NGROK_DOMAIN_INGRESS` documented in both dev env examples, beside the existing + `NGROK_AUTHTOKEN` text, which is left as it was. +- [x] `docker compose config` validates for both editions with both tunnels defined. +- [x] `pnpm run typecheck` clean; `pnpm test` 2117 passed. The 19 failures in + `commit-authorization`, `sandbox-agent-acp-interactions` and `workspace-import` + are pre-existing — confirmed by running the suite on the base commit with this + work stashed. + +## Deploy and verify — not mine to run + +Each item is here because it can fail quietly. + +- [ ] Set `NGROK_AUTHTOKEN`, and `NGROK_DOMAIN_INGRESS` if a reserved domain exists. +- [ ] Bring the stack up with the tunnel profile on (it is on by default), and pass + `--remove-orphans` the first time: the rename leaves an orphaned `ngrok` + container behind otherwise, which is confusing rather than harmful. +- [ ] **Both tunnels come up.** Two agent sessions are needed. If the plan allows only + one, the second will fail to start — that is the case to watch for, and the + fallback is a single agent with two named endpoints. +- [ ] **The ingress tunnel reaches the API.** `curl https:///api/health` + answers from the API. If it answers HTML, it reached `web` and the path is wrong. +- [ ] **The store tunnel still reaches the store**, and the runner still finds it: + `curl http://ngrok-mounts:4040/api/tunnels` from inside the network lists one tunnel + whose upstream is `seaweedfs:8333`. +- [ ] **A Daytona run with the bundled store still mounts its durable folder.** This is + the regression that matters most: it is what the first draft of this change broke. + Expect no `mount SKIPPED` warning. +- [ ] **A local-sandbox agent run still works.** Local sandboxes never tunnelled, so + this proves the compose edit broke nothing else. +- [ ] **With no token set, neither service publishes anything and neither loops.** Each + should exit 0 once and stay exited. + +## Then + +- [ ] Raise the PR against `main`. **The operator does this, not the agent.** +- [ ] Merge into `channels-m4`, which needs it for platform events. +- [ ] Merge into the gateways branch, which needs it for the client-identity fetch. + Its `D26` already says the OAuth **redirect** needs nothing, and that stays true + — do not let the merge imply otherwise. + +## Watch for + +- **Do not repoint or remove the store tunnel.** The first draft of this change did, + and it silently cost Daytona sandboxes their durable folder. The two tunnels are + independent on purpose. +- **If you rename either service again, move the runner's default with it.** The + service name is the only address the runner has for the store's tunnel daemon, so a + rename alone breaks discovery silently. +- **Do not route the store on a subpath.** S3 signatures cover the path, so a stripped + prefix invalidates every request. The store gets a host, never a prefix. +- **Do not write a routing rule against a literal bucket name.** + `AGENTA_STORE_BUCKET` is configuration and its defaults already disagree across + files. +- **A rotating tunnel address invalidates anything registered with a provider.** That + is what makes a reserved domain worth it rather than a nicety. diff --git a/docs/design/gateways-research/README.md b/docs/design/gateways-research/README.md new file mode 100644 index 0000000000..191122024e --- /dev/null +++ b/docs/design/gateways-research/README.md @@ -0,0 +1,58 @@ +# Gateways research + +Design for two gateways — an **LLM gateway** and an **MCP gateway** — treated as one problem +with two protocol surfaces. + +**Status: v1, in progress.** [`v1/`](v1/) is the design; its `README.md` carries the posture, +the reading order, and the scope. Documents there are structured but not complete — each +states what it must establish and what is still missing. + +There is no v2 and there should not be one until v1 is superseded rather than merely extended. + +## Why the two are one design + +They are the same shape. A run has exactly two kinds of outbound dependency needing a +credential we hold: the **model** it calls, and the **tools** it calls. Today both are +resolved and shipped outward as secrets. The runner wire already models them as two consumers +of one pattern — a route, a credential, a policy — and describes a gateway MCP server as "an +HTTP MCP server whose URL happens to be ours." + +Governance, identity, authorization, compliance, metering and routing are **the same six +concerns over different nouns**. Designing the two gateways separately means building that +plane twice and having it disagree with itself. That is the failure mode this research exists +to avoid. + +## Layout + +- **[`v1/`](v1/)** — the design. Start at its `README.md`. +- **[`v1/raw/`](v1/raw/)** — the research behind it: the codebase surveys, the protocol + findings, and the framing documents the design grew out of. Read these when you want to know + *why* a document says what it says. +- **`v1/notes.md`** — positions that were taken and reversed, with the reasoning. Read it when + a shape in the design looks wrong and you want to know whether it was already tried. +- **`v1/open-designs.md`** and **`v1/open-reviews.md`** — working documents: what is still + undecided, and what to verify against the code when the ports are implemented. + +## Relationship to the earlier tool-gateway research + +A prior docs-only effort covered the **tool/MCP** half: the provider landscape, why no +open-source equivalent of the incumbent catalog exists, the two auth layers, and a recommended +direction of "be an MCP client/gateway rather than clone a catalog vendor." + +That work is input, not a duplicate. This design keeps its conclusions on the tool side and +asks the question it did not: what happens when the model plane gets the same treatment, and +what do the two share. It also postdates that research on one material point — the protocol +revision it assumed has since been replaced, and `v1/mcp.md` covers what changed. + +## Scope + +In: the outbound plane for every caller — model calls and tool/MCP calls — and the identity, +policy, audit and metering both need. The credential model, including user-level credentials +designed but not scheduled. The self-hosted posture, since it is the reason this work exists +rather than adopting a hosted provider. + +Out: the tool **catalog** question, settled earlier as "do not become a catalog vendor." +Trigger delivery, a separate subsystem for structural reasons — the protocol is +request/response and carries no inbound events. Prompt and response transformation. The +internal first-party tool-delivery channel, which is a different concern and deliberately not +modelled here. diff --git a/docs/design/gateways-research/v1/README.md b/docs/design/gateways-research/v1/README.md new file mode 100644 index 0000000000..bcb3e35827 --- /dev/null +++ b/docs/design/gateways-research/v1/README.md @@ -0,0 +1,97 @@ +# Gateways + +Design for an LLM gateway and an MCP gateway, built as one policy core with two protocol +surfaces. + +**Status: skeleton.** `raw/` holds the source research behind these positions. Documents here +are structured but not complete — each states what it must establish and what is missing. + +## Posture + +Six claims anchor the design and constrain everything downstream: + +1. **Everything transits a gateway.** No bypass, no exceptions, custom providers included — + what is custom lives *behind* the gateway, never beside it. A governance boundary with an + exception is not a boundary. +2. **Identity is not a new problem.** Every authenticated call already resolves an + organization, workspace, project and user, and is rejected if any is missing. Both + gateways inherit that principal rather than inventing one. +3. **The gateways hold no secret material.** A domain row carries a secret id; the secrets + service holds the value; the consumer resolves it at use time. This is the pattern + webhook subscriptions and SSO providers already use. +4. **Most of the secret model is already built.** The auth-scheme axis, the + ready/needs-auth/needs-input state machine, the one hosted-redirect flow serving both + schemes, and the refresh and revoke ports all exist. The gateways **copy those shapes into + their own domain** rather than joining the one that has them — an integrations domain and a + traffic boundary share a word, not a concern. +5. **Transparent on the data path, never on the consent path.** The gateway absorbs + selection, injection, refresh, retry and audit. It cannot absorb consent, which needs a + human at first use and again on a step-up scope challenge. +6. **The gateway owns all six concerns, and this design owns the gateway.** Identity and + permissions, governance, secrets, and metering and billing. Three other efforts specify a + LLM gateway; they are callers of this one, not parallel designs (D11, D12). + +## Reading order + +1. **`decisions.md`** — what is settled, and the rationale load-bearing enough to constrain + future work. Everything else assumes these. +2. **`architecture.md`** — the shape: the two planes, where boundaries land relative to the + existing layering, and the path a call takes in each direction. +3. **`entities.md`** — the data model and its full stack, layer by layer, following the + repo's standard domain structure. +4. **`secrets.md`** — the vocabulary, secret kinds, ownership, and how user-level and + project-level secrets resolve against each other. +5. **`policy.md`** — the shared plane both gateways evaluate against: identity, + authorization, governance, audit, metering, routing. +6. **`contract.md`** — the ports. What callers speak to the gateway, and what the gateway + speaks to adapters. +7. **`mcp.md`** — everything MCP-specific. +8. **`models.md`** — everything model-provider-specific. +9. **`plan.md`** — the work packages and what depends on what. No sizing, no schedule. +10. **`notes.md`** — replaced designs and open observations. +11. **`workstreams/`** — one spec and one task list per package, plus the file-ownership + table and the parallel-work rules. + +Alongside the design, not part of its argument: + +- **`libraries.md`** — what to reuse rather than build, and what was rejected. Read before + implementing anything that looks like an OAuth client or a secret store. +- **`open-designs.md`** — design questions still open, ordered by what depends on them. +- **`open-reviews.md`** — what to verify against the code when the ports are implemented. +- **`cleanups.md`** — everything that becomes possible only **once the gateways run**, and + therefore cannot be scheduled in front of them. The full cost of "everything transits a + gateway", stated once. Read it before proposing any of its items as a prerequisite. +- **`raw/`** — the research this design grew out of: the codebase surveys, the protocol + findings, and the original framing. Read when you want to know why a document says what it + says. + +**Every document except `decisions.md` and `notes.md` states only what is.** A shape that was +proposed and replaced lives in `notes.md`; rationale that constrains future work lives in +`decisions.md`. + +## Why `mcp.md` and `models.md` exist + +They are quarantine. Protocol facts and provider facts change on someone else's schedule, +and letting them leak into `architecture.md` or `entities.md` makes those documents rot with +every upstream revision. The other documents stay protocol-neutral by pushing their +protocol-specific facts into these two. + +This is the same reason the channels design keeps one platform document: the neutral +documents are the ones that have to survive. + +## Scope + +In: the outbound plane for every caller — model calls and tool/MCP calls — and everything the +gateway owns for both: identity and permissions, governance, secrets, and metering and billing +(D12). The secret model, including user-level secrets designed but not scheduled. The +self-hosted posture, since it is the reason this work exists rather than adopting a hosted +provider. + +**Owned is not the same as scheduled.** A concern may arrive later; none is designed out. The +test for each increment is whether it forecloses a later one. + +Out: the tool **catalog** question, settled in the prior research as "do not become a catalog +vendor." Trigger delivery, which is a separate subsystem for structural reasons — the +protocol is request/response and carries no inbound events. Prompt and response +transformation. The internal first-party tool-delivery channel, which is a different concern +and is deliberately not modelled here. diff --git a/docs/design/gateways-research/v1/architecture.md b/docs/design/gateways-research/v1/architecture.md new file mode 100644 index 0000000000..18058568d1 --- /dev/null +++ b/docs/design/gateways-research/v1/architecture.md @@ -0,0 +1,117 @@ +# Gateways: architecture + +**Status: skeleton.** Section headings are settled; the content marked *to establish* is the +remaining work. + +--- + +## 1. What the feature is + +Two gateways — one for model calls, one for tool and MCP calls — sharing one policy core. +Every outbound call from every caller transits one of them. Callers name what they want and +authenticate to us; the gateway binds that name to a real route and a real secret. + +## 2. The shape + +The system is **one policy core, two protocol surfaces, and a set of adapters**. + +```text +callers ──▶ protocol surface ──▶ policy core ──▶ adapter ──▶ upstream + (north port) (south port) +``` + +- **North port** — what a caller speaks. Model plane: an OpenAI-compatible surface. Tool + plane: MCP over Streamable HTTP. Both authenticate with a secret we mint. +- **Policy core** — identity, authorization, governance, secret resolution, audit, + metering. Protocol-neutral. Shared by both surfaces; this sharing is the reason the two + gateways are one design. +- **South port** — adapters. Model providers and their deployments on one side, MCP servers + on the other. + +*To establish:* whether the core and the surfaces ship as one deployable or two, and where +the boundary sits relative to the main API. See `decisions.md` D1 and D2. + +## 3. Boundary rules + +*To establish.* Candidates, each of which needs stating as a rule or discarding: + +- No secret material crosses the north port outward. +- No caller-supplied value reaches an adapter without passing the policy core. +- The core never imports a protocol surface or a concrete adapter; wiring happens at the + entrypoint, per the repo's layering rule. + +## 4. Where this lands relative to the existing layering + +The repo's required direction is Router → Service → DAO interface → DAO implementation → DB, +with concrete dependencies wired only at the entrypoint, and DTO/DBE mapping isolated in the +DB layer. + +The gateways are a **separate domain that mirrors the existing gateway family's shape without +joining it**. That family — catalog, connections, tools, triggers — already has ports, +registries, services and per-provider adapters, so it is the structural precedent to copy. It is +not a family to enter. + +**Why the distinction is not pedantry.** Judged by what it holds rather than by what it is +called, that family is an *integrations* domain: its contracts are integrations and integration +keys, its one table is a connections table, and its only provider is Composio. The gateways are +traffic transiting a boundary — identity, policy, secret injection and metering, per call, on the +data path. Sharing ports and registries is true of every domain in this repo and proves nothing +about kinship. `notes.md` records the two drafts that concluded otherwise. + +Settled in `entities.md`: `core/gateways/` beside the existing `core/gateway/`, holding both +planes and the shared policy core, with matching folders under the storage and API layers. One +genuine reference to the older domain survives and is not evidence of kinship — a +Composio-brokered MCP server points at a connection row. + +## 5. The path of a model call + +*To establish.* Must cover: caller authenticates → principal resolved → policy evaluated → +secret resolved by owner and mode → adapter selected by provider and deployment → +upstream call → streaming response → usage recorded → audit written. + +Open within this: how streaming interacts with a policy decision that has to be made before +the first token, and what happens to a decision that expires mid-stream. + +## 6. The path of a tool call + +*To establish.* Must cover: caller authenticates → principal resolved → target resolved from +the request headers → policy and allowlist evaluated → secret resolved → upstream MCP +call → result returned → audit written. + +Open within this: the endpoint shape (one merged endpoint with namespaced tools, or one per +server), and how a list call composes across servers with differing secret health. + +## 7. What belongs to the platform, not here + +Tracing and metering pipelines, RBAC, entitlement checks, the secrets service, and the +approval mechanism all exist. The gateways emit into them and call them; they do not +reimplement any of them. + +*To establish:* the exact call into the entitlement check, and whether the gateway's own +decision caching layers on top of the existing two-layer pattern or replaces it for this +path. + +## 8. Security posture + +The gateway's central claim is that provider secrets stop at our boundary. + +Established: signing for cloud resellers moves to the gateway, so the secret category +that today must be held in an agent-controlled sandbox stops existing for gateway-routed +runs; and the per-run redaction set collapses to one short-lived token. + +*To establish:* the token's lifetime and scope, what it authorizes beyond identity, and what +an attacker holding one can do. + +## 9. Failure posture + +Fail-closed on policy, with the data plane able to serve cached decisions through a +control-plane outage. + +*To establish:* what "cached decision" means concretely — what is cached, keyed how, for how +long, and which classes of call are never served from cache. + +## 10. Extending to protocols we do not yet speak + +*To establish.* The adapter port should admit a new upstream kind without touching the core. +Whether that generalizes beyond models and MCP is worth stating one way or the other, since +an over-general port costs more than it returns. diff --git a/docs/design/gateways-research/v1/cleanups.md b/docs/design/gateways-research/v1/cleanups.md new file mode 100644 index 0000000000..983e010575 --- /dev/null +++ b/docs/design/gateways-research/v1/cleanups.md @@ -0,0 +1,256 @@ +# Gateways: the cleanup list + +Everything that becomes possible **once the gateways are running**, and cannot be done before. + +This document exists because the design kept mistaking outcomes for prerequisites. `notes.md` +records two cases where the reasoning ran backwards and produced a plan that could not start. The +rule that came out of it: **when something in the current code looks like it must be fixed first, +check whether the new design is what makes the fix possible.** If it is, it belongs here. + +Nothing on this list blocks any wave. Nothing on it is optional either — this is what "everything +transits a gateway" (D1) costs in full, stated once so the cost is not discovered piecemeal. + +**Status: a register, not a plan.** Each entry says what it is, why it cannot happen sooner, and +what done looks like. Sequencing comes later. + +--- + +## CU1. Close the plaintext secrets read surface + +**What.** The vault's read routes return decrypted material to any caller holding the view +permission: both `GET /secrets/` and `GET /secrets/{id_or_slug}` return the full payload, and the +create and update responses echo it too. Provider keys, client secrets, webhook keys and custom +secret content all come back in the clear. + +**Why not sooner.** Callers read that route because it is how they obtain a provider key at all. +Restricting it before they have another way simply breaks them. + +**Done.** Nothing outside the gateway resolves a secret through it, and the route is restricted or +removed. Parallel bring-your-own-secrets work wants the same outcome, so ownership has to be +agreed rather than assumed. Tracked as OR14 in `open-reviews.md`. + +## CU2. Remove module-level provider keys from the workflow handler — CLOSED + +**What.** One handler assigns provider keys to module-level attributes on the routing library +before each call, rather than passing them per call. That is process-wide state; in a shared +process it is a cross-tenant secret leak. + +**Why not sooner.** The pattern exists *because* nothing hands that handler a resolved +connection. Dependency injection through the gateway is what removes the reason for it. + +**Done.** Nothing assigns to the library's module attributes. Note that the handler in question is +reported unused and may be deleted outright, which would close this without any work. Tracked as +OR13. + +**Closed, and the "unused" premise was wrong.** `llm_v0` is mounted at `/llm/v0` in +`services/entrypoints/main.py` and registered under `agenta:builtin:llm:v0` — reachable, not dead +code. The code-side fix already landed separately (commit `50d6a2b3ed`, "per-entry llm_v0 keys"): +`_call_llm_with_fallback` no longer does `setattr(litellm, attr, key)` per family; it resolves +`provider_settings` per LLM entry through `SecretsManager.get_provider_settings_from_workflow` and +splats them into the per-call `acompletion(**kwargs)`. A repo-wide grep for +`litellm\.\w+_key\s*=`/`setattr(litellm, ...)` turns up nothing. Added two regression tests to +`test_llm_v0_provider_key_binding.py`: one asserting the fake litellm module gains no new +attributes across a call, one running two concurrent calls on different connections and asserting +neither call's key leaks into the other's kwargs. + +## CU5. Move the eligible slice of the runner's tool loopback to the gateway + +**What.** The runner synthesizes a loopback HTTP MCP server per run, because Claude Code and Codex +accept tools only over MCP while Pi receives them through a bundled extension. Calls relay back to +the runner, which applies private specs and callback auth server-side. On a remote sandbox the +loopback is unreachable, so a stdio shim is uploaded instead and tool calls become relay files a +runner-side loop polls. + +**The eligible slice is narrow:** `callback` tools with no `contextBindings`, no `ephemeralArgs`, +and permission `allow`. Those are plain third-party tool calls and could address the gateway +directly — `builtin/composio/notion/my-notion` instead of harness → loopback → runner → the tool +API → the provider. Two hops removed, and the gateway applies the policy instead of the tool +router. + +**What stays, and why it is not incidental.** `contextBindings` are executor-private argument +paths the runner fills from run context *after* the permission verdict, deliberately not +advertised to the model — per-run state a stateless gateway has neither. `ephemeralArgs` are +fields stripped so they reach the human and never the request. `client` tools pause the run and +relay to the caller, and permission `ask` raises human-in-the-loop approval through the runner's +own machinery. **The loopback is the runner's tool executor, not a transport**, and it shrinks +rather than disappears. + +**Why not sooner.** It cannot begin before the runner addresses the gateway at all, and putting a +runner change inside the wave whose job is standing the gateways up would confuse two risks. + +**Done.** The eligible slice is served by the gateway, the ineligible slice is still served by the +runner, and the boundary between them is written down rather than folklore. + +## CU6. Collapse the wire's secret arrays — CLOSED + +**What.** The runner's request carries per-server secret arrays for MCP and a secret array +for the model. If the gateway holds every upstream secret, those collapse to a single minted +token. + +**Why not sooner.** They cannot collapse while anything still needs a real upstream secret +delivered to a sandbox. + +**Closed, already an outcome of WP12/WP13/WP15.** Both arrays carry one gateway token on the +connected path: the model's `credentials` stays empty and its one token rides the separate +`gatewayCredentials` field (D36); each MCP server's `credentials` array holds exactly one entry, +`X-AG-Credentials`, regardless of how many named secret refs the author declared. Verified with a +new regression test proving the collapse holds across more than one server at once, not only the +single-server case the existing tests covered. + +`local_use` survives, and the reason is narrow: `_resolve_from_secrets` (the connected `agenta`- +mode path) never emits it any more — `build_gateway_resolved_connection` returns `credentials: []` +for every deployment, bedrock and vertex included, so cloud-reseller signing has already moved +behind the gateway there. The category is still reachable from the two offline, standalone-SDK +resolvers (`EnvConnectionResolver`, `StaticConnectionResolver` in `connections/resolver.py`), +which exist for SDK usage with no Agenta backend and therefore no gateway to hold a reseller +secret for — the sandbox has nobody else's account to sign with, so the value has to be real. +`daytona-secret-plan.ts`'s allowlist already carries this exact reasoning inline. Tracked as OR6. + +## CU7. Simplify the redaction deny-set + +**What.** The runner builds a per-run deny-set from every secret value on the wire, so no +secret can reach a log. + +**Why not sooner.** The complexity is proportional to the number of distinct secrets on the wire. + +**Done.** Once item 6 leaves one short-lived token, the deny-set's construction is re-assessed +rather than inherited. Tracked as OR7. + +## CU8. Widen the hard-coded provider couplings, together + +**What.** Provider names are pinned in more places than a reader expects: the static model +catalogue and the two maps derived from it; two secret-kind enums naming providers; the harness +capability table's vault-provider set, subscription set, model aliases, and per-harness custom +deployment map; a provider-to-base-URL map; and a provider-kind alias map. The +provider-to-environment-variable map exists **twice**, once in the SDK and once mirrored by hand +in the runner's TypeScript. + +**Why not sooner.** Widening them piecemeal produces a half-open system where a provider works in +one layer and not the next. Doing it once, after the gateway defines what a provider *is*, is +cheaper and verifiable. + +**Done.** Adding a provider touches a declared set of places, and the two copies of the +environment-variable map either agree by construction or become one. Tracked as OR8. + +## CU9. Converge the duplicated auth-scheme and connection-state definitions + +**What.** The `oauth | api_key` scheme enum and the ready / needs-auth / needs-input state machine +each exist in three parallel copies across the catalog, tool and trigger domains, and the gateways +add a fourth inside their own boundary (D27's separate-domain layout). + +**Why not sooner.** Those three domains are outside the current scope (D15), and importing the +gateways' vocabulary from one of them would couple a traffic boundary to an integrations domain +through the back door — worse than a fourth copy. + +**Done.** All four resolve to one definition in the shared DTO module that already holds the +identifier, slug and header types. Tracked as OR4. + +## CU10. Remove the legacy credits counter + +**What.** A credits counter increments today whenever a caller checks access to platform-owned +secrets — once per access check rather than per usage. It measures nothing anybody wants. + +**Why not sooner.** It is the only thing counting anything on that path. + +**Done.** Removed, once the gateway is the sole mechanism the whole system uses (D24). + +## CU11. Fix the callback path hardcoded to one consumer + +**What.** The connections service builds its OAuth callback URL against the tool domain's mount, +`/tools/connections/callback`, although the trigger domain creates connections through the same +service. The comment explains it as preserving a public contract when the connection moved into +its own domain, which explains it without justifying inheriting it. + +**Why not sooner.** Changing a registered redirect path is a coordinated change with whatever has +already registered it. + +**Done.** The callback path is a property of the connections domain rather than of one consumer, +and a third consumer needs no new special case. + +## CU12. Collapse the four copies of the outbound SSRF guard + +**What.** The same guard — block private, loopback, link-local, reserved, multicast and +unspecified addresses, refuse plain `http`, resolve once and pin the literal IP — now exists four +times. In the API at `core/webhooks/utils.py`, whose three functions the gateways import (D28). In +the SDK at `agenta/sdk/utils/net.py`, whose own docstring says "unify these three if a clean shared +package ever spans API + SDK". In the SDK again, inline in the workflow handler as +`_validate_webhook_url`. And in TypeScript at `services/runner/src/tools/ssrf-guard.ts`, which +carries a hand-transcribed copy of Python's `ipaddress` special-registry tables and a comment +saying they once drifted apart. + +**Why not sooner.** Two of the copies are in a different language and a third is in a package that +ships to users, so there is no import that spans them today. Collapsing the two Python ones is +possible now, and the gateway does not need it: it imports the API copy, exactly as EE's +organization service already does. + +**Why the gateway makes it worth doing.** Until now each copy guarded one narrow path — a webhook, +an OIDC issuer, a custom provider base URL. The gateway makes this guard the single control on +every outbound call the platform makes on a tenant's behalf, so the copies stop being four small +risks and become one contract with four implementations that can disagree. + +**Also on this item, and larger than the duplication:** `AGENTA_INSECURE_EGRESS_ALLOWED` defaults +to `true` and is set in no deployment configuration in this repo, so every copy is currently +inert. The default exists so zero-config self-hosting works, which is a real requirement; what is +missing is that a shared deployment turns it off. C1 verifies with it `false`, and +cloud setting it `false` is a deployment action rather than an assumption. + +**Done.** One definition per language, with the range tables generated or tested against each +other rather than transcribed, and the flag explicitly `false` wherever more than one tenant +shares a deployment. + +## CU13. Turn the insecure-egress default off wherever a deployment is shared + +**What.** `AGENTA_INSECURE_EGRESS_ALLOWED` was set in no deployment configuration in this repo, and +all four copies of CU12's outbound guard default unset to `true` (CU14), so every copy was inert +everywhere. The permissive default exists so zero-config self-hosting works, which is a real +requirement; what was missing is that a deployment serving more than one tenant turns it off. + +**Why it is its own item and not part of CU12.** CU12 is duplication — four implementations of one +contract that can drift. This is posture: one flag, set nowhere, that leaves every copy open. +Collapsing the copies does not change it, and turning it off does not need the copies collapsed. +Bundling them would let the slower half hold the faster one. + +**Why not sooner.** It was not wrong sooner — before the gateways, each copy guarded one narrow +path. The gateway makes this guard the single control on every outbound call the platform makes on +a tenant's behalf, which is what turns an inert flag from untidy into load-bearing. + +**Done.** The flag is explicitly `false` in every configuration where more than one tenant shares a +deployment, and a test asserts the guard actually refuses a private address under that setting +rather than assuming it. + +## CU14. One default for the insecure-egress flag: permissive — CLOSED + +**What.** `AGENTA_INSECURE_EGRESS_ALLOWED` meant opposite things when unset depending on which copy +of CU12's guard read it: the API's `WebhooksConfig` and the runner's `insecureEgressAllowed()` +resolved it permissive, while both SDK copies resolved it secure. A guard that is on in one layer +and off in another for the identical unset variable is not one control with four implementations; it +is two controls sharing a name, and nobody reading any single copy would know. + +**Settled: unset is permissive, in all four.** The default exists so zero-config self-hosting works, +which is the same reason CU13 keeps the dev configurations permissive. A default that blocks +loopback and private addresses breaks a single-tenant install out of the box, and the SDK — the copy +most likely to run on a developer's own machine against their own services — is the worst place to +choose the strict answer. Posture for shared deployments comes from CU13 setting the flag explicitly +to `false` where more than one tenant shares a deployment, not from the default. + +**The tests moved with it.** Asserting the flag resolves `true` no longer distinguishes the variable +being read from the default being returned, so the env-resolution tests assert the discriminating +direction instead: an explicit `false` produces `false`, and an ambient `false` that the fixture +clears still produces the permissive default. + +**Done.** All four copies resolve unset to `true`, and CU13 carries the per-deployment `false` so +the two are read together. + +--- + +## What is not on this list + +**Anything that is genuinely a prerequisite.** If something must be true before a wave can start, +it belongs in `plan.md` as a work package, not here. The test is whether the gateway is what makes +it possible. + +**Retries, model fallbacks and aliasing.** Never planned, not deferred — `scope-checklist.md` +records them as outside this work entirely. + +**Usage recording and charging, and per-endpoint configuration.** Deferred rather than unlocked: +they are gateway work that ships after C3, and `plan.md` carries them. diff --git a/docs/design/gateways-research/v1/contract.md b/docs/design/gateways-research/v1/contract.md new file mode 100644 index 0000000000..f3db25d67f --- /dev/null +++ b/docs/design/gateways-research/v1/contract.md @@ -0,0 +1,76 @@ +# Gateways: the ports + +The two ports and what crosses them. Channels needed a wire contract because third parties +would implement adapters out of process; here the reason is different — **both ports have +externally-fixed shapes we do not control**, so the contract is mostly about what we add +rather than what we invent. + +**Status: skeleton.** + +## North port — what callers speak + +Two surfaces, one authentication story. + +### Authentication + +Every caller presents a secret we mint plus a gateway URL. The caller never holds an +upstream secret and never changes shape when an upstream switches auth scheme. + +*To establish:* the token's format, lifetime, scope, and what it authorizes beyond identity. +It must be worthless outside our gateway — that property is the point. + +### Model surface + +An OpenAI-compatible surface, because every harness and the routing library already speak it. + +*To establish:* which endpoints, how a policy denial is expressed within a fixed error shape, +and how a harness that authenticates with its own subscription login is handled given it +injects no secret today. + +### Tool surface + +MCP over Streamable HTTP, targeting the stateless revision. Routing reads the required method +and target headers rather than the body. + +The endpoint shape is settled: **one URL per server**, the identifier in it namespaced, and the +proxy transparent — same tool names, same schemas, same errors (D19 and D20 on what an endpoint +is and which ones are stored; D16 on the shape). + +*To establish:* how list caching interacts with per-caller tool allowlists, given list results +now carry a shared-intermediary scope flag. + +### What the wire looks like from the runner + +Nothing new. The runner contract already expresses a gateway route on both consumers, and its +commentary anticipates a gateway MCP server as an HTTP server whose URL happens to be ours. + +**Adoption is a resolver-side change for that caller** — the contract, the golden fixtures +that pin it on both sides, and the harnesses are untouched. Expect the per-server secret +arrays and the model secret array to collapse to a single gateway token. + +## South port — what the gateway speaks to adapters + +An adapter turns a resolved route plus a resolved secret into an upstream call. The core +never imports one; wiring happens at the entrypoint, per the repo's layering rule. + +*To establish:* the interface itself. It must admit at minimum a model provider by deployment +kind, and an MCP server by auth mode, without either shape leaking into the core. + +The existing family already has this pattern — ports, a registry keyed by provider, and +per-provider adapters — so the precedent is in-tree rather than invented. + +## What is deliberately not a contract + +- **The adapters' own protocols.** Provider APIs and the MCP wire are someone else's contract; + we conform, we do not define. That is why `mcp.md` and `models.md` exist as quarantine. +- **An out-of-process adapter contract.** Channels needed one because third parties would + implement bridges. No equivalent need has been established here, and inventing one would + add a compatibility surface with no consumer. Revisit only if a real case appears. + +## Open + +- Whether the two north surfaces share a router layer or are independent. +- Whether the south port is one interface with two shapes or two interfaces sharing a + secret-resolution helper. +- Versioning. The MCP surface is pinned to a protocol revision that moves; the model surface + is pinned to a de facto standard that also moves. Neither versioning story is designed. diff --git a/docs/design/gateways-research/v1/decisions.md b/docs/design/gateways-research/v1/decisions.md new file mode 100644 index 0000000000..3fe235fd66 --- /dev/null +++ b/docs/design/gateways-research/v1/decisions.md @@ -0,0 +1,965 @@ +# Gateways: decisions + +What is settled, and the rationale load-bearing enough to constrain future work. Everything +else in `v1/` assumes these and states only what is. + +Where another document elaborates a decision, that document +is the authority for detail; this one owns the rationale. + +--- + +## D1. Everything transits a gateway + +No direct path and no bypass. Every model call and every tool call from anything on the +platform goes to a gateway first, and the gateway decides what happens next. + +This includes everything custom. A custom provider, a self-hosted model server, a cloud +reseller, an OpenAI-compatible third party — none becomes an exception. 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 it is invariant.** + +**Why absolute:** a governance boundary with an exception is not a boundary. If any path +reaches a provider directly, no claim about policy, audit, spend or secret containment +holds — "did every call get checked" becomes "every call except those." One bypass costs the +whole property. + +**What it costs:** changes throughout, not in one place. Every call site that resolves a +secret and calls a provider becomes one that calls the gateway. That is the real scope +and should not be understated. + +## D2. The principal already exists and is user-scoped + +Every authenticated call resolves an organization, workspace, project and user together, and +is rejected if any is missing. API keys included — the key row carries its owning user. + +The gateways inherit this. **There is no principal to design.** Which secret the gateway +then uses on the caller's behalf is a separate binding, settled in `secrets.md`; caller +identity and secret ownership are independent, and conflating them is what made this look +harder than it is. + +## D3. The gateways hold no secret material + +A domain row carries a secret id; the secrets service holds the value; the consumer resolves +it at use time. This is the pattern webhook subscriptions and SSO providers already use. + +**Why it matters beyond tidiness:** encryption, key management, rotation and deletion stay in +one place instead of gaining a second. It also removes what looked like the design's one new +component — there is no token store, only new secret kinds (D14). + +This is about *secrets* — customer provider material. The credentials that authenticate a +caller into a gateway are a different thing and are not stored at all (D13). + +## D4. Ports and adapters everywhere, including inside the SDK + +The SDK keeps every capability it has today, including injecting and fetching secrets. Those +are not removed and calling code does not change shape. What changes is the implementation +behind the port: the adapter that resolved a secret and called a provider now calls the +gateway. + +Nothing here is "in-process." A caller depends on a port; the adapter behind it talks to the +gateway. + +## D5. Agent runs and workflows are separate callers + +Different callers of the same gateway, with different ports, designed separately. Reasoning +about them as one path produces conclusions wrong for both. + +## D6. Transparent on the data path, never on the consent path + +The gateway absorbs secret selection, injection, refresh, retry, allowlists and audit. It +cannot absorb consent, which needs a human at first use and again on a step-up scope +challenge. No amount of internal configuration changes that. + +**Consequence:** consent is a dashboard action taken before a run, not a runtime one. A run +reaching an unconnected upstream fails with something actionable. Every remaining choice in +this area is about *where the consent moment goes*, not whether there is one. + +## D7. One policy core, two protocol surfaces + +The six concerns are the same over different nouns, and building the plane twice is the +failure this design exists to avoid. Whether it ships as one deployable or two is an +operational choice that can follow. + +**This decision is falsifiable and should be watched.** If `policy.md` ends up as two +documents with little in common, D7 is wrong and the gateways should be separate systems. + +## D8. Target the stateless protocol revision + +The current MCP revision removed exactly the features that made a gateway expensive — +sessions, resumability, server-initiated callbacks — and added three that favour +intermediaries. Build against it rather than the prior revision the earlier research assumed. + +## D9. Embed the commodity, own the policy + +Provider adapters, streaming differences, reseller auth schemes and the MCP OAuth client flow +are commodity work that drifts constantly and is freely available. Identity, policy, audit and +metering are ours and no embedded gateway will model them the way we need. + +Owning the wrong half is the expensive mistake in either direction. + +## D10. Take the secret owner as a parameter now + +User-level secrets are designed and not scheduled. The lookup must still take the owner +from the outset and answer "the project" for now. + +**Why now:** the signature is the expensive part to retrofit. A lookup assuming the project +spreads that assumption to every call site; a lookup taking an owner absorbs user-level +secrets as a storage change. The caller side needs nothing either way, since the principal +already carries the user. + +## D11. This design owns the gateway + +Four efforts specify an LLM gateway (`raw/related-work.md`). **This one owns the gateway +design.** The others are inputs to it and consumers of it, not parallel designs. + +Concretely: the credits ledger and the trial grant are **callers** of the gateway. They decide +what a run may spend. They do not decide what the gateway is, nor does either ship a second +request path. + +**Why one owner:** the mechanism in all four is identical — the same signed run token, the same +endpoint, the same secret swap at the boundary. Only the trigger differs. Four owners of one +mechanism produces four subtly different versions of it, and the boundary claims then hold for +none of them. + +Work already done elsewhere is adopted rather than repeated. The run token, the north port +shape, and the process placement all come from the credits design and are better than what this +document had (D2's open mechanism, in particular). + +## D12. The gateway owns all six concerns, delivered incrementally + +The gateway owns identity and permissions, governance, secrets, and metering and billing. +Not a subset. + +**This settles the scope conflict** in `raw/related-work.md`. Two parallel efforts scope the +gateway to funded runs only. That is a **delivery phase, not the design**. D1 stands: the target +is that everything transits, and a funded-only first version is a step toward it rather than a +different destination. + +**Incrementally means the concerns arrive in an order, not that any is out of scope.** A +concern may be unimplemented; none may be designed out. The test for each increment is whether +it forecloses a later one — the secret lookup taking an owner from the outset (D10) is the +pattern, and recording real usage from the first day even when charging a flat price is the +same move on the billing side. + +**What this rules out:** a second request path for any concern. If billing needs something the +gateway does not expose, the gateway grows it. Billing does not route around it. + +## D13. The inbound credentials are minted, ephemeral, and never stored + +The credentials that authenticate a caller **into** a gateway are not a new kind of thing and +do not live in the vault. By the established vocabulary they are *credentials* — Agenta's own +auth — not a *secret*, which is customer provider material. + +**It is minted per use and expires, and the mechanism already exists.** `sign_secret_token` +produces an HS256 JWT carrying the user, project, workspace and organization plus an expiry, +currently 15 minutes. It travels as `Secret `, one of three accepted authorization +schemes beside `Bearer` and `ApiKey`, and the middleware verifies it by decode alone — no +database read. The access router re-mints one on every permission check rather than echoing an +API key, and **the workflow invoke prelude already signs one per run** for batch and detached +invokes, centralised so the two paths cannot drift on auth. The gateways use that signer. + +**One token per target, minted in a batch.** A run reaching three MCP servers gets three +tokens in one minting call, each valid for one target. A leaked token then reaches one server, +not the whole gateway. The wire already carries per-server credentials, so nothing new is +needed to deliver them. + +**Why ephemeral beats a durable scoped key.** There is nothing at rest to steal, nothing to +rotate, no revocation path to build, and no new secret kind. Expiry is the revocation. A +durable per-endpoint key would be better than passing the user's own API key, and worse than +this. + +**Why not the user's own API key.** It carries everything that user can do, it cannot be +rotated without breaking their other integrations, and it would sit inside an agent-controlled +sandbox. + +**One gateway-wide token, unchanged, for now.** The existing secret token is enough: a caller +presents it and the gateway authorises per call through the normal permission path. No target +claim, no permitted set, no new claims. + +Per-endpoint tokens and a permitted set in the payload are a **later** step, arriving with +user-owned secrets. The grain then goes project, then user, then endpoint — finer at each step. +Batch minting is an optimisation and not part of any of this. + +The cost of the simple version is a permission lookup per call rather than a signature check. +That is what the rest of the API already does, and it keeps a revoked permission effective +immediately rather than at the next mint. + +**The known failure mode.** Per-turn secret material must stay out of any session +fingerprint that decides whether a warm session may be reused. The runner already excludes its +tool-callback bearer from the secret epoch for this reason, and a regression that folded +per-turn material into that hash has already been fixed once. Gateway tokens are the same kind +of material and inherit the same rule. + +## D14. Two OAuth secret kinds, and no static MCP kind + +**`oauth_provider`** holds our client registration with an authorization server. The existing +SSO kind is the precedent in both name and shape — it already stores a client id, a client +secret, an issuer URL and scopes. + +**`oauth_grant`** holds a user's tokens: access, refresh, expiry, granted scopes, and the +server the token was minted for. + +**Two kinds, not sub-kinds of one.** 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. These two share no fields, and they differ in cardinality (one per +authorization server versus one per user per server), in lifetime, in rotation frequency, and +in 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. + +**No new kind for static MCP secrets in this scope.** Under 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, and that is deferred rather than designed away. + +**Never overload an existing kind.** The general-purpose custom secret and custom provider kinds +exist for other things. Coordinate with the parallel bring-your-own-secrets work, which is adding +kinds to this same enum for sandbox providers and the tool gateway key. + +## D15. Current scope is the gateways, agent v0, the runner, and the harnesses + +No other service changes yet. The evaluator path — including the two callers that use embeddings +rather than chat — comes later, and so does the question of whether embeddings share the model +registry. + +D1 remains the target. This is where it starts. + +## D16. One URL per MCP server, namespaced identifier, pass-through + +Each registered server gets its own gateway URL. The identifier in that URL carries a namespace, +because a bare name identifies nothing once there are Composio-backed servers, Agenta-internal +ones, built-ins, and user-defined custom ones — some per-user, some project-wide. It is an id or +a slug, never a display name. + +Because the server is already distinguished by its URL, **tool names are not touched**. The +gateway is a transparent proxy per server, not a wrapper: same tool names, same schemas, same +errors, same list responses. It changes the route and the secret and nothing else the agent +can observe. + +A merged endpoint with namespaced tool names was rejected. It would rename what the model sees, +tie the tool list to secret health, and fight the list caching the protocol now encourages. + +## D17. Step-up scopes are an interaction, not a failure + +Two halves, both needed. + +**At connect time the user selects scopes.** Not a blanket request for everything the server +advertises — offer the set and let them choose. + +**At step-up the gateway raises an interaction.** When a call needs a permission that was never +granted, this is the same situation as a tool needing a connection that does not exist yet, and +that path already exists: an interaction and a connect affordance rather than a failure. Step-up +reuses it, asking for additional permissions on an existing connection. + +Failing with a clear error was rejected: it is the same situation as a missing connection, where +we already do not fail. + +## D18. A dead secret does not hide tools + +When a secret is revoked or cannot refresh, the server's tools stay listed and the call fails. +The existing escalation and interaction paths let the user reconnect. + +Hiding tools was rejected because it changes what the agent can do without telling anyone. +Anything beyond this is a question about the interface, which this design does not settle. + +## D19. A gateway endpoint is a server, not a model or a tool + +An LLM gateway endpoint is a **provider**, which may serve many models. An MCP gateway endpoint +is a **server**, which may serve many tools. The two gateways are symmetric. + +This is why a per-model endpoint is wrong: it would make the LLM gateway asymmetric with the MCP +one and multiply endpoints by the size of a provider's catalogue. + +## D20. Standard endpoints are generated; only custom ones are stored + +Everything needed to reach a standard provider is deterministic. The provider-to-models +catalogue already lives in the SDK as a static map — eleven providers, each with its model list, +with costs derived from the routing library. So a standard endpoint's route is derivable rather +than persisted: a stable prefix, the `builtin` namespace, and the provider's own name. No slug, +because the provider name is the identifier. + +**The URL spells that namespace `builtin`, not `standard`** (D27). The secrets domain's word for a +non-custom provider is *standard*; the path segment is `builtin` on both planes; the mapping +between them lives in one place. A public path segment does not owe its spelling to an internal +enum. + +**The CRUD surface therefore stores only custom endpoints.** A standard endpoint exists when a +key exists for it. The same split applies to the tool plane: `agenta` and `builtin` servers are +ours to define or broker, and only `custom` ones are rows. + +## D21. Configuration is per endpoint, and only custom endpoints are configurable + +Timeouts, ceilings and extra headers are one concern, not three, and they apply to both +gateways. + +**Editability follows the same split as D20.** Standard and built-in endpoints are ours to +define; users do not edit them. The configuration surface exists for **custom** endpoints on +both gateways. + +Configuration lives **per endpoint**. A per-provider-kind layer may earn its place as a static +default; a global layer does not. Each additional layer needs somewhere to live and a precedence +rule, so add them only when something needs them. + +## D22. The audit record is an event, not a new table + +An events domain already exists across core, API, storage and an asynchronous worker. Its event +type carries a request id and event id, a request type and event type, a timestamp, a status +code and message, and a free-form attributes map, with a query surface beside it. + +The gateways emit into that. They do not add a second event pipeline or an audit table. + +## D23. The gateways must be mockable, and the mocks come first + +Nothing behind either gateway can be a third-party dependency in tests. A mock LLM endpoint and +a mock MCP server are **first-class deliverables of the first checkpoint**, not test scaffolding +added afterwards. + +This is also what makes the first checkpoint coherent: with no OAuth and no static secret kind +in it, the only reachable targets are our own servers and the mocks. That is a complete target +set rather than a gap. + +## D24. The legacy credits counter is left alone + +A credits counter is incremented today when a caller checks access to platform-owned secrets. +It is legacy. It is not moved, not reinterpreted, and not fixed as part of this work. + +It is removed only once the gateway is the sole mechanism the whole system uses. + +## D27. Three namespaces on both planes — and the namespace picks the backend + +**SUPERSEDED IN PART BY D30**, which replaces the set with `builtin` / `standard` / `custom` +and demotes `agenta` to a provider inside `builtin`. What survives here: the spelling rule, +the reserve-all-three rule, and the backend-selection table below. + +**Spelled without a hyphen.** The namespace is a path segment in every gateway URL, so it stays +one lowercase word: `builtin`, never `built-in`. + +**The same three words on both planes**, and all three are reserved from the start even where one +has no members yet. Taking a keyword costs nothing now; discovering later that something else took +it costs a migration of live URLs. + +| Namespace | LLM plane | MCP plane | +|---|---|---| +| `agenta` | **Reserved, empty today.** Where an Agenta-owned or fine-tuned model would live | The Agenta tools. The mocks are its first members (D23) | +| `builtin` | The generated standard-provider set (D20) | Third-party servers shipped ready to click, backed by the Composio catalog the integrations domain already consumes | +| `custom` | A stored endpoint row: a customer's own deployment or reseller | A stored endpoint row: a server the user brought by URL | + +**`builtin` aliases the standard-provider path internally, and that is fine.** The secrets domain +calls a non-custom provider *standard*, and the URL says `builtin`. The gateway maps one to the +other in one place. **A public path segment does not owe its spelling to an internal enum** — the +alternative was two vocabularies for one idea, split across the two planes, purely to avoid a +two-line mapping. + +**The runner's loopback channel is not in this picture.** It is a loopback — a per-run transport +that hands first-party tools to a harness — and it stays exactly that. The exclusion in `notes.md` +holds. The Agenta tools are the Agenta tools; how a particular run happens to receive them is a +separate concern and always was. + +### The namespace selects the backend + +That is what makes it worth being in the path rather than in a column. + +| Hit | The gateway calls | +|---|---| +| `agenta` | The Agenta tools | +| `builtin` | Composio, reusing the connection the integrations domain already brokered | +| `custom` | The upstream the endpoint row names, with the secret we resolved for it | + +### The route grammar + +```text +/gateways/mcps/builtin/agenta/{slug} agenta/tools +/gateways/mcps/builtin/{provider}/{integration}/{connection} builtin/composio/notion/my-notion +/gateways/mcps/custom/{slug} custom/acme-notion + +/gateways/llms/agenta/{slug} reserved, empty today +/gateways/llms/standard/{provider} builtin/openai +/gateways/llms/custom/{slug} custom/acme-azure +``` + +**The words are the ones the codebase already uses.** `provider`, `integration` and `connection` +are `provider_key`, `integration_key` and the connection's slug — the three columns of the +existing connection table's unique key, `(project_id, provider_key, integration_key, slug)`, +minus the project, which comes from the token. Naming them anything else would invent a second +vocabulary for one set of values. + +**Identifiers are slugs or keys, never display names.** An `agenta` identifier is a slug we own +and **may be nested**, so its route segment is a path rather than a single component. A `custom` +identifier is one slug, unique within the project. + +**The model plane's version segment is not part of this grammar.** `/v1` belongs to the upstream +protocol's own path — an OpenAI-compatible client appends `/v1/chat/completions` to whatever base +it is handed, which is why the existing endpoint map in the SDK stores +`https://api.openai.com/v1` for OpenAI and a bare host for Anthropic. We hand out a base ending +`/v1` for the same reason. **The tool plane has no equivalent**, because the whole MCP protocol is +a POST to the endpoint URL itself with no path structure after it, and the protocol revision is +negotiated in a header rather than a path. + +So there is no `/v1` on a tool endpoint. Versioning *our own* surface is a separate question, it +applies to the whole API rather than to one plane, and no route in this codebase carries a +version segment today. `contract.md` keeps it open. + +**The broker is named in the path, and that is deliberate.** An earlier draft hid it behind a +stable name so a provider swap would not change URLs. That is the wrong instinct twice over. The +provider is part of a connection's identity rather than an implementation detail — the existing +connection table already keys on `(project, provider_key, integration_key, slug)` — and two +connections to the same vendor through different brokers carry different secrets, different +consent and different tokens. A URL that survived a backend swap would keep resolving while the +user's tokens did not migrate, which hides a real breakage instead of showing it. Naming the +broker also lets a brokered server and a direct one to the same vendor coexist. + +The in-tree precedent agrees: the tool call reference is +`tools.{provider}.{integration}.{action}.{connection}`, and the catalog routes are +`/catalog/providers/{provider_key}/integrations/{integration_key}`. + +**The extra segment on the tool plane is real structure, not an inconsistency.** On the model +plane the provider *is* the server, so nothing follows it. A broker is not a server; it fronts +many, so the one being addressed has to be named. That is D19 applied honestly to a broker. + +**The rule an interim draft added does not survive, and it is worth saying why.** That draft kept +the broker's integration key in a field we mapped through, so their rename would be a mapping +change rather than a broken URL. The final grammar puts their key in the path instead, and the two +cannot both hold. + +The path wins for the same reason the broker is named there at all: if a broker renames an +integration, that is a real change to what a stored URL points at, and a URL that quietly kept +resolving would hide it. Vendor integration keys are stable in practice, and the cost of the rare +rename is a visible break rather than a silent one. + +### Why `builtin` is Composio-backed + +A dashboard user clicks *Notion*. They do not type Notion's URL. So something must already hold +the display name, the icon, the description and the URL — and that something is a catalog we +would otherwise curate by hand, forever, per server. + +**We already consume one.** The catalog contract in the existing integrations domain carries the +key, name, description, categories, **logo**, url and auth schemes for the whole Composio +integration set, and Composio hosts MCP endpoints with the secret lifecycle on their side. +The connection state machine and the connect affordance are in the tree and already drive the +tool domain. Nothing new is curated and nothing new is maintained. + +**What is deliberately not stored, and this is what keeps the catalog small.** Not the OAuth +endpoints, and not even the scope list. Given the server's URL, both are fetched at configuration +time with no secret at all: an unauthenticated call returns a challenge naming the +protected-resource metadata, that names the authorization server, and the authorization server +publishes its endpoints together with the scopes it supports. So the dashboard renders real scope +checkboxes from a live call rather than from a stored field, which is what connect-time scope +selection requires (D17). + +**The catalog is therefore five fields:** name, icon, description or category, and URL. + +### `builtin` reuses the brokered connection; `custom` runs our own flow + +The two are different mechanisms, not two configurations of one. + +**`builtin`** rides what already exists. The integrations domain brokers the authorization, +stores the connection row, and holds the secret upstream; the MCP endpoint references that +row and the gateway relays to Composio's endpoint. There is no OAuth client of ours in the path, +no grant row, and no new secret. What still needs designing is the reference itself — which row, +keyed how, and what happens to the endpoint when the connection is revoked. + +**`custom`** is the full journey and the only reason our OAuth client exists. The user supplies a +URL; the gateway discovers the authorization server from the server itself; our client runs the +authorization with proof key exchange; the tokens land in the vault as an `oauth_grant` secret +(D14) with the grant row pointing at it; and a later call resolves that secret and resumes. This +is the path that exercises everything the OAuth wave builds. + +### The cost of this, stated plainly + +With `builtin` meaning only Composio, our own OAuth client is exercised only by `custom` servers, +and "everything transits our gateway" becomes "everything transits our gateway, which transits +theirs." We own the policy, the audit and the identity; we do not own the vendor relationship. + +That is a reasonable place to start and a poor place to stay. Whether a small set of **direct** +built-in servers exists from the beginning is open — `open-designs.md` OD13 carries it, along with +the maintenance pattern this repo already uses for a comparable catalog. + +## D26. The OAuth redirect needs nothing built + +**There is no redirect problem.** The user is already looking at the Agenta interface in a +browser when they click connect. Whatever address got them there is an address their browser +reaches. The authorization server does not fetch the redirect target; it only tells the browser +where to go next, and the browser has already proved it can get there. + +That holds in every deployment: + +| Deployment | The redirect address | +|---|---| +| Cloud | Our domain | +| Self-hosted, production | Their domain. A production web application has one, or nobody can log in | +| Development | The existing tunnel service, already wired into the development compose files | + +**The tunnel stays development-only.** It is in the development compose files under a profile, +gated on an authorization token, exiting quietly when none is set, and the runner already +discovers its public address at runtime through the tunnel agent's own API. That is the right +scope for it. Outside development a deployment has a domain, and if an operator chooses to run a +tunnel anyway that is their arrangement, not something the product ships. + +**The tunnels belong to the development-ingress work, and the gateways never add one.** That +work owns the tunnel services, their names, and the runner's selector; this design builds on top +of them and changes none of them. Three reasons a gateway-specific tunnel is not merely +unnecessary but harmful: + +- **It would publish something already published.** The ingress tunnel forwards to Traefik, so + every inbound route arrives on its normal path — the gateways are behind Traefik under `/api/` + like everything else. A second tunnel to the same place adds no reach. That work states the + rule directly: *"Do not add a tunnel per integration. One endpoint serves all of them"*, and it + names the model and MCP gateways as one of the three consumers it was built for. +- **It would break tunnel selection, silently.** The runner used to take the first HTTPS tunnel + it found, which was correct only while one existed. The ingress work replaces that with a match + on the upstream a tunnel forwards to, precisely because a second tunnel makes order-based + selection wrong. A third re-enters that space, and the failure is quiet: a sandbox handed the + platform's HTTP API where it expected the object store. +- **Each tunnel is a live agent session.** Two already risk exceeding a provider plan — that work + documents a single-agent fallback for exactly this. A third makes it likelier that the store + tunnel fails, and Daytona sandboxes depend on that one for a durable working folder. + +So the only inbound need this design has is the client-identity fetch below, it belongs to the +OAuth wave rather than to C1, and the ingress tunnel already serves it. + +### The one thing that can genuinely fail + +The newer client-registration mechanism makes the client identifier an HTTPS URL and has **the +authorization server fetch it** to read the application's name and permitted redirect addresses. +That fetch comes from the public internet, so it needs a publicly resolvable name — which a +deployment on an internal-only domain does not have, even though its own users reach it fine. + +The answer is the older registration mechanism: the deployment posts its own metadata outbound to +the authorization server and receives an identifier. Nothing is ever fetched from us. It is +deprecated in general and it is the correct choice here, because it is the one path with no +inbound direction at all. + +So the rule is: **prefer the document, fall back to registering outbound**, and a deployment on an +internal domain simply always takes the fallback. + +### What was rejected, and why it was wrong + +A hosted service on our domain to receive redirects for deployments that could not. It solved a +deployment shape that does not exist — a production web application with no address — and it +would have added a shared component holding other customers' authorization codes for no reason. +The mistake was reasoning about network topology in the abstract instead of asking how the user +got to the connect button in the first place. + +## D25. A governance ceiling rejects; it never silently clamps + +When a call exceeds a ceiling the platform set, the gateway denies it and says so. It does not +quietly lower the value and proceed. + +**The distinction that makes this non-obvious.** A stated value can collide with a *physical* +limit or with an *operator's* limit, and those deserve opposite answers. Asking for more output +tokens than the context window holds is impossible, and the ecosystem is converging on treating +such a value as an upper bound and clamping it — that is the upstream's job, not ours. Our +ceilings are the other kind, and every comparable gateway rejects those: a managed API gateway's +token policy answers with distinct rate and quota statuses, and another's prompt guard and size +limiter both refuse rather than edit. + +**Why silence is the wrong default here.** A ceiling exists to be accounted for. Lowering a value +quietly produces a run whose output differs from what was asked for with nothing explaining why, +and the compliance claim the ceiling exists to support stops being verifiable from the caller's +side. + +**What makes rejection tolerable** is the content of the denial: it names the ceiling, the value +asked for, and the value allowed, so a caller retries correctly the first time. `open-designs.md` +records the evidence. + +## D28. The outbound guard is the one the repo already has, called at both ends + +A custom endpoint's URL is typed by a user, and the gateway is the process that connects to it. +That is a server-side request forgery sink: without a guard, a tenant can point an endpoint at +`http://169.254.169.254/` and have us fetch a cloud provider's instance secrets for them, with our own network +position and our own outbound allowances. + +**Nothing new gets written.** `api/oss/src/core/webhooks/utils.py` already implements exactly this +guard, and three call sites already use it — webhook delivery, the EE organization OIDC issuer, and +the custom-provider URL on a secret. Its three functions are the whole vocabulary we need: + +- `validate_url_format_and_literal_ip(url)` — scheme, host, no embedded credentials, and a literal-IP + block, with **no DNS lookup**. This is the save-time gate; it exists because resolving at save time + would reject a hostname that happens to be momentarily unresolvable. +- `resolve_validated_webhook_ip(url) -> str` — the same checks plus a DNS resolution, returning the + single literal IP the caller must connect to. +- `validate_webhook_url(url)` — the same, discarding the IP. + +Blocked means private, loopback, link-local (which is what covers the metadata address), reserved, +multicast or unspecified, and plain `http` is refused alongside them. + +**Both ends, because one end is not enough.** Validating only at registration leaves the window +between saving a row and using it, during which the hostname's DNS answer can change. Validating +only at relay time means a plainly-bad URL is accepted, stored, and fails later at a confusing +moment. So: registration calls the no-DNS gate, and relay calls the resolving one. + +**Relay connects to the returned IP, not the hostname.** This is the part that is easy to drop and +is the only reason the resolving variant returns a value at all. `core/webhooks/delivery.py`'s +`send_webhook_request` is the worked example: swap the host in the URL for the literal IP, set the +`Host` header back to the original authority, and pass `extensions={"sni_hostname": ...}` so TLS +still validates against the real name. Re-resolving the hostname at connect time reopens the rebind +window the check just closed. + +**The runner's version is the closest sibling and contributes two refinements.** +`services/runner/src/engines/sandbox_agent/mcp.ts::validateUserMcpUrl` guards a user-supplied MCP +URL today, against a TypeScript range table deliberately mirrored from the Python one. It adds a +host allowlist read from `AGENTA_AGENT_MCPS_HOST_ALLOWLIST`, so a self-hoster can permit one known +internal server without disabling the guard globally; and it separates "could not be resolved" from +"resolves somewhere blocked", so an operator reading a DNS typo does not see a security rejection. +Both are worth carrying. The runner cannot pin the resolved IP because it hands the URL to a harness +that reconnects; the gateway makes the call itself, so it can, and should. + +**The flag that turns it all off is on by default.** `AGENTA_INSECURE_EGRESS_ALLOWED` defaults to +`true` in `api/oss/src/utils/env.py` — zero-config self-hosting is the reason — and it is set in no +deployment configuration in this repo, cloud included. So the guard as it stands is inert in +production. Two consequences, both on C1's list rather than deferred: the acceptance check +runs with the flag `false`, and turning it `false` on a shared deployment is a named deployment +action rather than an assumption. + +**What is deliberately not decided here** is where the guard's code should live. Its home is a +webhook module and there are now four near-copies of it across the API, the SDK and the runner. The +gateway imports the API one exactly as EE's organization service already does — a cross-domain +import with precedent — and `cleanups.md` carries the consolidation. + +## D29. No entitlement gate on the gateways; entitlements ship with metering + +Every user has both gateways. There is no plan that grants one and withholds the other, so a +soft entitlement check in wave 1 would ask a question with only one answer. + +**What entitlements will really express here is a limit** — calls, tokens, spend — not access. +And a limit cannot be enforced before anything is measured: the check needs a counter, the +counter needs a grain, and the grain is the thing `scope-checklist.md` already defers to the +billing wave because only knowing what will be billed answers it. So the entitlement check moves +to sit beside usage recording and charging, which already ship together for the same reason. + +This closes R5, which asked which entitlement key to gate on. The honest answer is none: no flag +or counter in the entitlements catalogue fits, the nearest candidate is the legacy credits counter +D24 forbids reusing, and inventing a key to satisfy a call that always permits would leave a +placeholder for someone to mistake for enforcement. + +**What stays.** `EntitlementDeniedError` remains declared in the seed and mapped to 403 at the +boundary, on the same reasoning as `MCPScopeInsufficientError` (§5): the type costs nothing, and +having it now means the wave that adds limits changes a body rather than a signature. The +permission check is untouched and remains wave 1 — permissions and entitlements answer different +questions, and conflating them is a known trap. + +--- + +## D30. Three namespaces — `builtin`, `standard`, `custom` — split by whose secret pays. Supersedes D27's set + +D27 named the three `agenta` / `builtin` / `custom` and treated `builtin` as an alias of the +secrets domain's *standard*. That alias was the error. The two are not the same idea, and the +difference is the one that decides who gets billed. + +| Namespace | Whose secret | Who pays the upstream | LLM plane | MCP plane | +|---|---|---|---|---| +| `builtin` | **Agenta's.** We hold the account | Us, so we charge the caller | Reserved today; where a model we supply the key for lands | `agenta` tools and `composio` tools | +| `standard` | **The user's.** We only know the shape | The user, directly | The generated provider set — openai, anthropic, and the rest | Reserved, empty today | +| `custom` | The user's | The user, directly | A stored row: their own deployment or reseller | A stored row: a server they brought by URL | + +**`standard` is not `builtin` under another name.** A standard target is one whose wire we already +know — the models, the base URL, the auth shape — so the user picks it from a list instead of +typing it. They still bring the key, and the provider bills them. A builtin target is one we own +the account for: nothing to bring, and the cost lands on us before we pass it on. Metering attaches +to `builtin` alone; `standard` and `custom` need no charging path at all. Calling both `builtin` +would have put a billing boundary inside a namespace. + +**`agenta` is a provider inside `builtin`, not a namespace of its own.** It was never a fourth kind +of thing — it is us, as one supplier among the builtin suppliers, next to `composio`. So the +builtin path carries a provider segment and the rest is that provider's own grammar: +`/builtin/agenta/tools`, `/builtin/composio/{integration}/{connection}`. + +**The internal vocabulary mismatch D27 absorbed disappears.** The secrets domain says *standard* +and now so does the URL. The two-line mapping D27 defended is deleted rather than defended. + +**Each plane still reserves all three**, for D27's original reason: taking a keyword costs nothing +now, discovering later that something else took it costs a migration of live URLs. Today LLM's +`builtin` is empty and MCP's `standard` is empty. + +**No migration.** `namespace` was never a column — every stored row is `custom` and the other two +are derived. The change is the enum, the route grammar and the catalogue's naming. + +## D31. The inbound credentials travel in `X-AG-Credentials`, which outranks `Authorization` + +D13 settled *what* authenticates a caller into the gateway — minted, ephemeral, never stored. +It put that token in `Authorization: Secret `, one of the three schemes the middleware +already accepts. That placement is wrong for one route shape, and the fix is a header. + +**Why `Authorization` cannot be the only door.** On a subscription pass-through route (D32) +`Authorization` carries the caller's own vendor authentication, forwarded unchanged to the +vendor. It is not ours to read and not ours to replace. A gateway that insists on +`Authorization` for its own identity has no way to accept both, which is exactly the +configuration pass-through requires. + +**The rule is precedence, not fallback.** `X-AG-Credentials` wins whenever it is present; +`Authorization` remains for every existing caller and every harness that has only one slot. +Reading it the other way round — `Authorization` first, `X-AG-Credentials` as a fallback — +fails precisely in the case the header was introduced for, because both are present and the +wrong one is ours. One helper, consulted at both of the middleware's existing read sites. + +**On the data plane it is not merely preferred — it is the only header we read.** The +proxies are ours to specify and have no legacy callers: no user traffic passes before +C3, and wave 2's callers are the SDK, the runner and agent v0. Requiring it there +buys a property worth more than the flexibility it costs — **`Authorization` on a data-plane +request is always the caller's**, never ours, in every request rather than in the ones we +can prove. The management CRUD routes are ordinary API routes and keep all three schemes. + +That property is what makes the outbound rule trivial. The relay strips `X-AG-Credentials` +and nothing else of the caller's; every other header it forwards. If the endpoint resolves a +secret, the adapter overwrites that provider's own auth header with it; if it does not, +whatever the caller sent stands and reaches the upstream. Pass-through then needs no +detection step and no provider-keyed table of auth headers (OD15) — it is what happens by +default when we have no secret to overwrite with. + +**Both are stripped before any relay.** They authenticate the caller into the gateway; neither +belongs to an upstream. This is one shared frozen set in the gateways' domain root, applied on +both planes, and it closes a real leak: the MCP adapter forwarded caller headers wholesale and +dropped `Authorization` only when it had a secret of its own to substitute — so a NONE-scheme +tool server received the caller's platform token. + +**What this does not change.** The token itself, its signer, its expiry, its claims, or the +three accepted schemes. D13 stands entirely; the value is the same string, and only where it +is read changes. Nothing needs a new secret kind, and the value stays out of any session +fingerprint (D13's known failure mode) for the same reason. + +## D32. Subscription pass-through is a fourth funding shape, orthogonal to the namespace + +D30 splits namespaces by whose *secret* pays: `builtin` is ours and bills through us, +`standard` and `custom` are the user's. Pass-through is none of those. The vendor authenticates +and bills the user's own **subscription**; the authentication stays in the harness; the gateway +holds no secret at all and contributes identity, policy, audit and attribution. + +**It is a separate axis, not a fourth namespace.** A namespace answers "which backend, and +whose key". Pass-through answers "who authenticates" — and the same target could in principle +be reached either way. Modelling it as a namespace would force a false choice between the two +questions and take a fourth URL keyword for an answer that is not about routing. + +**What it demands that nothing else does.** The gateway must *not* inject an upstream secret, +must not overwrite `Authorization`, and must forward the caller's vendor authentication +untouched — the exact inverse of every path built so far, all of which derive `Authorization` +from a resolved secret and overwrite whatever was there. That inversion is why it needs a +decision before it is built rather than after. + +**It adds a mode to the LLM plane, which today has none.** Every model upstream +authenticates one way — a secret we resolve and inject — which is why `llms_endpoints` +carries no `auth_mode` column while `mcps_endpoints` does (entities.md §2.4). Pass-through +is the second mode, and it needs no column: since this decision's sibling D31 moved our own +credentials to `X-AG-Credentials`, an upstream auth header we did not authenticate with is +the caller declaring pass-through, which works on a generated `standard` endpoint that has +no row at all (`open-designs.md` OD15). + +**Explicitly not built here, and not because it is unimportant.** It depends on facts about +harness releases that no design can assert — whether a given harness will send a second header +while keeping its vendor login, and whether that login survives a base-URL override. Building +against a guess is what makes this expensive. The prerequisite is a matrix test per harness, +tracked in `open-designs.md`. + +**What is refused outright, and stays refused.** Centralising or replaying vendor subscription +session files as a substitute for per-user vendor auth. They are the user's own secrets, often +device-bound and renewable, and holding them would make us the custodian of exactly the thing +this design exists to avoid holding. If the gateway user and the subscription principal must be +proven to be the same person, that needs a provider-supported identity claim or an explicit +account-pairing flow — never token parsing. + +## D33. The protocol front door is a route dimension; one today, more later + +The gateway relays a protocol; it does not translate between protocols. Each native protocol is +its own front door under the plane, and a request never crosses from one to another: + +```text +/v1/chat/completions -> OpenAI Chat Completions (built) +/v1/responses -> OpenAI Responses (later) +/v1/messages -> Anthropic Messages (later) +``` + +**Why front doors rather than one normalised entry.** Translating one provider's tool-use, +reasoning, cache and structured-output semantics into another's is a permanent maintenance +liability that grows with every provider release, and it breaks upstream prompt caching, which +is the thing the byte-for-byte relay exists to protect. Adding a front door is additive: a +parser for that protocol's model field, a route, and the same policy pipeline behind it. + +**D34 makes this the only mechanism, not one of two.** Since no body may be converted, an +upstream is reachable exactly when a front door speaks its protocol — so adding front doors +is how the reachable set grows, and there is no second route through a converting adapter. + +**What each new front door 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. + +## D34. The gateway never converts a body. Not the request, not the response, ever + +The relay carries bytes. It may choose *where* to send them and *how to authenticate* +sending them; it may never change what they say. That holds for the request body and the +response body, streamed or not, on both planes. + +**The three things an adapter does are not one thing, and only two of them are allowed.** + +- **Routing** — composing a URL from route fields: Azure's + `/openai/deployments/{deployment}/chat/completions` and its `api-version` parameter, + Bedrock's `/model/{id}/invoke`, a reseller's `base_url` plus the protocol's own path. + Allowed, and unremarkable: it is what a proxy is. +- **Authentication** — presenting the secret the way the upstream wants it: a bearer header, + a differently-named header like Azure's `api-key`, a SigV4 signature, a token minted from + a service account. Allowed, and sometimes unavoidable — an upstream that authenticates by + signing leaves no other option. Calling this "translation" was a category error: nothing + about the caller's request changes, only how we prove we may send it. +- **Body conversion** — rewriting a Chat Completions request into Anthropic Messages, or + the reverse, or reconstructing a response from parsed objects. **Forbidden.** + +**Why forbidden, and not merely discouraged.** Conversion is the one thing that makes the +gateway lossy. It breaks byte-for-byte, and with it the upstream's prompt caching, which is +the property this design exists to preserve. It silently drops whatever the target format +has no field for — reasoning traces, cache markers, structured-output modes, provider +extensions shipped last week — and the loss is invisible to the caller, who sees a plausible +answer built from less than they sent. And it is unbounded work: every provider release is a +new mapping to maintain, forever, in the one place a bug is hardest to see. + +**What follows: an upstream is reachable when a front door speaks its protocol.** This makes +D33's front doors the mechanism rather than an optimisation. Chat Completions reaches +OpenAI-shaped upstreams; Anthropic Messages reaches Anthropic and the Bedrock and Vertex +models that take that body; and a provider whose protocol has no front door is not reachable +through the gateway until one exists. That is a real and deliberate limit, and it is +narrower than it sounds, because a harness already speaks its vendor's protocol — the +front door and the caller match by construction. + +**Translation does not disappear; it moves to the client.** A caller that wants one shape +across many providers builds the provider-shaped request itself, with whatever library it +likes, and sends those bytes through the matching front door. The loss then happens where +the caller can see it and chose it, rather than inside a relay that promised not to look. +The routing library keeps two jobs on our side that are not conversion: cost arithmetic +after the fact, and signing where the auth scheme is a signature. + +**What this supersedes.** The `passthrough` / `translated` adapter split, which named the +forbidden job as if it were a peer of the allowed ones. What replaces it is one relay with a +routing strategy and an authentication strategy per deployment. `open-designs.md` OD16 +holds what is left: which upstreams that reclassification actually reaches, verified per +provider rather than argued. + + +## D35. A gateway target must be registered before an agent can use it + +Wave 2 made this true in code before it was written down here, so it is recorded now rather +than left as an implicit consequence. + +**The rule.** An agent reaches a model or an MCP server only through a target that already +exists in the gateway's registry: an endpoint row for a `custom` target, a stored secret for a +`standard` one, a brokered account for a `builtin` one. The SDK no longer honours a URL and a +secret declared in agent code. It routes by name and the gateway resolves the rest. + +**Why it follows from the wave rather than being a new constraint.** A secret declared in agent +code is a secret inside the sandbox, which is exactly what C2 closes. There is no +version of "the author brings their own URL and token" that also satisfies "no third-party +secret in a sandbox". So the CRUD registries on both planes are not administrative convenience; +they are the only place a secret can live once the sandbox cannot hold one. + +**What it costs, stated plainly.** An author who used to point at a server URL in code must now +register that server first. That is a real workflow change and it needs the two affordances +below, neither of which is built. + +**Consequence 1 — a refusal must arrive as a cause, not as a failure.** When a credential is +missing, expired or rejected, or a named target does not exist, the gateway already raises a +typed domain error. What is not proven is that the cause survives the whole chain back to the +agent: gateway to harness to runner to agent service to the caller. WP13 added +`AgentErrorDetail` (`{code, message, retryable, next_step?, details?}`) to the runner wire and a +best-effort recovery of the cause from the harness's own error text, and flagged that whether a +given harness preserves the gateway's response body is unverified per harness. WP14 noted the +agent service does not yet surface the field onto its own stream. Until both are closed, an +unregistered target or a dead credential reaches the agent as prose, and an agent that cannot +read the cause cannot act on it. + +**Consequence 2 — an agent must be able to ask for the connection it lacks.** The affordance +already 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. The symmetric case is an agent +that names a model or a server it may not reach and asks the user to connect it, rather than +failing and stopping. Extending the existing tool is the shape to prefer over a second +mechanism, since the pause, the render hint and the resume path are all already built. + +**Both consequences are wave-3 scope.** Neither was in wave 2's checkpoint, and neither should +be retrofitted into a package that has already merged. + + +## D36-D39. The wave-2 launch rulings + +Four shape rulings that had to settle before wave 2's packages could start. Their full text +lives in [`workstreams/launch-2.md`](workstreams/launch-2.md), beside the seed they governed, +rather than being restated here: D36 (our credentials are their own field, not a widened +binding), D37 (loopback is exempt from the https requirement, explicitly), D38 (all three +protocol front doors ship together), D39 (the seed owns the mock upstreams' header echo). + +They carry D-numbers because the prefix vocabulary reserves `W` for waves: WP work packages, +CU cleanups, IM intermediate merges, C checkpoints, W waves. Numbering is absolute within a +workstream and never restarts per wave. + + +## D40. A named, static field rewrite is allowed where a resold wire demands it. Amends D34 + +D34 forbids the gateway converting a body, and that stands. This carves out one bounded exception, +because one vendor resells Anthropic's Messages wire with a fixed structural difference that no +caller-side workaround reaches without giving up the front door entirely. + +**One entry, not two.** The table held a Bedrock entry until OD19 found it was an artefact of a +routing choice rather than a fact about the vendor's wire. Bedrock's Messages door was originally +composed against `InvokeModel` on `bedrock-runtime`, which needs exactly this rewrite — +`anthropic_version` added to the body, `model` moved out of it into the URL. But Bedrock also +publishes `bedrock-mantle.{region}.api.aws`, a second endpoint that serves the Anthropic Messages +API natively: the 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 — no body rewrite at +all. Routing Bedrock's Messages door there instead (OD19) made the entry unnecessary and it comes +out of the table. Vertex has no equivalent second door; its Anthropic path is `rawPredict` only, so +its entry stands. + +**The fact, from the vendor's own documentation.** + +| | Vertex `rawPredict` | +| --- | --- | +| Body must contain | `anthropic_version: "vertex-2023-10-16"` | +| Model id | in the URL, not the body | + +Anthropic states it directly: "`model` is not passed in the request body. Instead, it is specified +in the Google Cloud endpoint URL", and "`anthropic_version` is passed in the request body (rather +than as a header), and must be set to the value `vertex-2023-10-16`". Note the asymmetry with the +native API, where `anthropic-version` is a **header**. + +**So the rewrite is two operations, not one.** Add a constant field; remove `model`. It was +described as additive when first proposed and that was wrong. Both operations are still static: the +key names are fixed, the added value is a per-deployment constant, and nothing is read from the body +to decide either one. + +**The rule this carves out.** A deployment may declare a static table entry of the form +`{fields_added: {name: constant}, fields_removed: [name]}`. Both lists are literal. **Nothing in the +table may be computed from the request** — not from its content, not from its size, not from another +field's value. An entry that needs to look at the body to decide is conversion, and is refused. + +**Why this is not D34 by another name.** D34 exists because conversion is unbounded: once the +gateway rewrites a body by understanding it, it owns every provider's schema forever, and every +schema change becomes our outage. A fixed key with a fixed value is not understanding — it is +addressing that the vendor happened to put in the body instead of the URL or a header, which is +exactly where the same value lives on the native API. The bound is the table's literalness, and it +is checkable by reading the table. + +**What it costs, stated so nobody discovers it in a test.** For Vertex the relay is no longer +byte-identical: adding and removing a key means re-serializing, and `content-length` changes. +Byte-for-byte relay stays the rule and the acceptance criterion everywhere else, with Vertex named +as the one exemption rather than the assertion quietly weakened. Bedrock is not exempt from +anything — its Messages door relays byte-for-byte like every other deployment now. + +**Whether `model` in the body is rejected or ignored, on Vertex: unattested either way.** The +removal happens regardless, because it is the same table entry and removing a field the endpoint +does not read costs nothing. (Bedrock's `InvokeModel` validator was attested to reject an unknown +`model` key with `Malformed input request: #: extraneous key [model] is not permitted` — the +finding that first established the removal half was necessary — but that operation is no longer +what this package routes to, so it no longer bears on the table.) + + +--- + +## Still open + +Tracked in [`open-designs.md`](open-designs.md) until they settle here. Two earlier blockers +are now closed — the model call sites are counted and the routing library runs in-process +(`raw/model-call-sites.md`). + +Four items previously listed here have since settled and are now decisions above: the MCP +endpoint shape (D16), step-up scope handling (D17), and embeddings, which are deferred with the +whole evaluator path (D15) rather than answered. + +What remains: + +- Which wave each capability lands in, marked in `scope-checklist.md`. This subsumes the older + question about the order the six concerns arrive in. diff --git a/docs/design/gateways-research/v1/entities.md b/docs/design/gateways-research/v1/entities.md new file mode 100644 index 0000000000..f0c932e384 --- /dev/null +++ b/docs/design/gateways-research/v1/entities.md @@ -0,0 +1,2908 @@ +# Gateways: entities + +The data model and its full stack, following the codebase's existing layering. +Column lists are the proposal, not migrations. + +The gateways have no sibling domain to mirror, and — despite the name — the existing +`core/gateway/` is not it. That domain is an integrations surface ("connect my project to +GitHub"); this one is traffic transiting a boundary. §1 makes the argument. The gateways +are therefore a **new domain**, one parent holding both planes and their shared core, +which is D7 expressed as a directory: one system, two protocol surfaces, one policy core. + +Three layout decisions are made here and argued in §1: the planes live under a new +plural parent `core/gateways/`, a **sibling** to the existing singular `core/gateway/`, +not inside it; the data plane and the management CRUD are **separate router objects** in +one API folder per plane; and the model plane's folder is named `llms/`, not `models/`. + +```text +core/gateways/ <- NEW parent, sibling to the existing core/gateway/ (§1) + dtos.py our shared vocabulary — auth scheme, connection state, + connect affordance, endpoint config (§4) + types.py GatewaysError, the base exception + policy/ the shared core both planes evaluate against (D7, D12) + dtos.py principal-adjacent DTOs: decision, target, outcome, secret triple + types.py policy + resolution exceptions + interfaces.py SecretsResolverInterface — the one lookup, owner-first (D10) + resolution.py the resolve() implementation over VaultService (WP2) + service.py GatewayPolicyService: authorize, audit, usage (WP3, WP4) + audit.py EventType members + attribute builders for the events stream (D22) + llms/ the model plane (WP1, WP6, WP7) + dtos.py enums + core DTOs + types.py domain exceptions + interfaces.py LLMEndpointsDAOInterface + LLMUpstreamInterface (south port) + registry.py adapter key -> LLMUpstreamInterface + catalog.py the standard set, generated from the SDK's static provider + map and its direct base URLs (D20, D30) + service.py LLMGatewayService: management + the data-plane relay + providers/ + passthrough/adapter.py OpenAI-compatible upstreams, byte-for-byte relay + translated/adapter.py the routing library in-process (superseded by D34; + see open-designs.md OD16) + mock/adapter.py the mock LLM endpoint (D23, WP5) + mcps/ the tool plane (WP1, WP8, WP9) + dtos.py + types.py + interfaces.py MCPEndpointsDAOInterface + MCPUpstreamInterface + registry.py upstream kind -> MCPUpstreamInterface + token_storage.py the MCP SDK's TokenStorage protocol over the vault (WP17, OR1) + service.py MCPGatewayService: management + the transparent proxy + providers/ + http/adapter.py remote Streamable HTTP servers (custom) + composio/adapter.py the builtin/composio relay — reuses the brokered connection (D30) + mock/adapter.py the mock MCP server (D23, WP5) +dbs/postgres/gateways/ + llms/ dbas.py, dbes.py, dao.py, mappings.py + mcps/ dbas.py, dbes.py, dao.py, mappings.py +apis/fastapi/gateways/ + exceptions.py handle_gateway_exceptions(), written once (§9) + llms/ models.py, router.py (management CRUD), proxy.py (OpenAI surface), + utils.py (call-context parsing off the body) + mcps/ models.py, router.py (management CRUD), proxy.py (MCP surface), + utils.py (protocol header parsing) +``` + +The existing `core/gateway/`, `dbs/postgres/gateway/` and their tables are untouched in +this scope — including `gateway_connections`, whose ownership work is designed, not +scheduled (`secrets.md`). + +Changed in place, in later work packages: `core/secrets/` gains the two OAuth kinds +(enum member, settings DTO pair, union arm, validator branch — WP16, §4); +`core/events/types.py` gains two `EventType` members (WP4, §4); +`core/access/permissions/types.py` gains six `Permission` members (§9). + +Not shown: the deployable mocks. The adapter-level mocks above satisfy unit and contract +tests; 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. + +--- + +## 1. The tables + +Two new, two reused, one deliberately left alone. + +| table | what it is | what it is not | +| --- | --- | --- | +| `llms_endpoints` | one **custom** LLM endpoint: a reachable provider deployment, its route, its model allowlist, its configuration | not a model, and not the catalogue — standard endpoints are generated, never stored (D20, D30) | +| `mcps_endpoints` | one **custom** MCP server: its URL, auth mode, tool policy, configuration, and — when OAuth-protected — the `secret_id` pointing at its tokens | not a catalog of tools — the server owns its tool list (D19); not a token store — the `oauth_grant` secret is (D3, D14) | +| `secrets` *(reused)* | gains two kinds, `oauth_provider` and `oauth_grant`; the payload is one encrypted blob, so **no schema change** | not gaining an owner column — every gateway secret is project-owned, full stop (`out-of-scope.md`) | +| events stream *(reused)* | one audit record per call, carrying decision, principal, owner, payer and usage | not a new table and not a second pipeline (D22) | +| `gateway_connections` *(existing)* | untouched; a Composio-brokered MCP endpoint will reference a row here | not the endpoint registry, and not our domain — see below | + +Reading it as a sentence: *a project registers custom endpoints on either plane, each +naming its own secret directly; a standard endpoint exists the moment a key exists for it; +a builtin endpoint exists because we run or broker it; and every call, allowed or denied, +becomes one event.* + +### Why the planes are a separate domain, not members of `core/gateway/` + +The skeleton proposed putting the planes inside the existing `core/gateway/`, and the +tempting argument for it — that the folder already has ports, a registry, services and +per-provider adapters, so the gateways "extend the family" — does not survive +examination. **Structural similarity is not domain kinship.** Every well-built domain in +this repo has ports, a registry and adapters; by that test the gateways belong everywhere, +which is to say the test proves nothing. + +What `core/gateway/` actually is, read from its own contents rather than its name: its +DTOs are `CatalogIntegration`, `CatalogProvider`, `integration_key`; its one table is +`gateway_connections`; its consumers are tools and triggers; its only working provider is +Composio. It is an **integrations** domain — "connect my project to GitHub" — that happens +to be named "gateway". The name is the accident. What this design builds is different in +kind, not in degree: traffic transiting a boundary — identity, policy, secret injection, +audit, metering, per call, on the data path. The two share a word and nothing else, and a +domain boundary drawn on a shared word is how unrelated code grows entangled. + +So the gateways are a **new domain**: `core/gateways/`, plural, a sibling of the existing +singular folder. The plural parent is deliberate and is D7 as a directory — one system, +two protocol surfaces (`llms/`, `mcps/`), one shared core (`policy/`), inside one parent — +where three loose top-level siblings would leave the policy core homeless and make the +"one design" claim invisible in the tree. + +**The one genuine connection between the two domains, stated so nobody re-litigates it:** +a Composio-brokered MCP server will point at a `gateway_connections` row — the Composio +account that fronts it lives there, and our `mcps_endpoints` registry references +it the way any domain references another's entities. A registry referencing a +neighbouring domain's rows is normal, and it is not a reason to live in that neighbour's +folder. + +**Why `llms/`, not `models/`.** A folder called `models` whose API layer contains a +`models.py` holding wire models is self-parody, and the product noun throughout `v1/` is +"the LLM gateway". `llms/` +and `mcps/` are also symmetric, which D19 says the two gateways are. + +### Why the table names compact the domain to its plane + +The tables are `llms_endpoints` and `mcps_endpoints` — the plane, then the +entity, both plural, and **no `gateway` anywhere in the name**. + +Two things are being avoided. A `gateways_*` prefix would sort directly beside +`gateway_connections` in every schema listing and read as kin to a domain this design just +argued its way out of, even though `core/gateway/connections/` ↔ `gateway_connections` sets +a mirror-the-path precedent. And a `llm_gateway_*` infix spends a word on a qualifier that +earns nothing: within this schema the plane already says which gateway it is, and outside +it nobody is looking for these tables under `g`. + +What is left is the domain compacted to the part that identifies it. Table names are read +far more often in isolation — in `psql`, in a migration, in an incident — than folder paths +are, so the name leads with the plane, which is what someone scanning a schema actually +needs. + +The naming rule, stated once so nobody re-derives it wrongly: **directories, URL segments +and tables are plural** (`gateways/`, `llms/`, `mcps/`, `/gateways/llms/...`, +`llms_endpoints`); **symbols and operation ids keep the singular adjective and the +qualifier** (`LLMGatewayService`, `llm_gateway_chat_completions_standard`), because a class +name has no schema around it to supply the context a table name gets for free. + +### The API folder, and why the data plane and the CRUD do not share a router object + +The domain has an API folder per plane, `apis/fastapi/gateways/{llms,mcps}/` — unremarkable +in itself; every domain with HTTP surfaces has one. The part that needs deciding is +inside it. + +The data plane and the management CRUD share the folder and the service; they do not +share a router class or its conventions. +The management CRUD follows the house shape — envelopes with `count`, `operation_id`, +`response_model_exclude_none`, a permission check per handler. The data plane must not: an +OpenAI-compatible surface and an MCP Streamable HTTP surface have **externally-fixed +shapes** — fixed paths, fixed error bodies, streaming, and a byte-for-byte relay constraint +(`scope-checklist.md`). Wrapping either in the house envelope breaks every client. So each +API folder holds `router.py` (house rules) and `proxy.py` (protocol rules), two router +objects wired separately at the entrypoint — the same move `triggers` makes with its +`router` and `admin_router`, for the same reason: one domain, two audiences with +incompatible conventions. + +### Why the endpoint rows do not reuse `gateway_connections` + +With the domain boundary drawn, this answers itself. `gateway_connections` is one +authorization of one integration — a provider-side account, a redirect flow, `is_valid` +driven by provider callbacks. An endpoint is a **route plus policy** — a URL, an +allowlist, a configuration — with a vault reference where a secret exists. Different +domain, different noun, different lifecycle; reuse would put our semantics on a table +tools and triggers read, and would leave `provider_key`/`integration_key` meaningless on +our rows. The tokens an OAuth flow eventually produces on the MCP side are named by the +endpoint's own `secret_id` (§2.1) — a project-owned server has exactly one secret to point +at, so the pointer lives on the row itself rather than in a table keyed by an owner nobody +stores (`out-of-scope.md`). The reuse that *is* correct is the reference stated above: a +Composio-brokered MCP endpoint points at its connection row; it does not become one. + +### Why the endpoint names its secret directly + +The vault holds the tokens (`oauth_grant`, D14); the gateway holds no secret material (D3). +But the vault payload is a `PGPString` blob — encrypted at rest, invisible to SQL — so +resolving a secret can never be a query inside `secrets`. Something unencrypted must point +at the vault row, and because every gateway secret is project-owned (`out-of-scope.md`), +that pointer needs no owner key of its own — it is one column, `secret_id`, on the endpoint +that uses it (§2.1). The row also gives the operational facts a home the payload cannot +serve — `is_valid` after a failed refresh (D18), the refresh attempt's outcome in `status` +— without decrypting anything. User-level secrets, should they ever ship, are a pure +add-on to this shape rather than a rework of it: a second table per plane narrowing the +answer for one member, with nothing here moving (`out-of-scope.md`). + +### Why a provider key is a scan, not a stored binding + +The asymmetry is deliberate and follows from what each secret *is*. An OAuth token is +**audience-bound**: minted for one server, by that server's authorization server — so the +binding `endpoint → secret` is a fact the row must carry, and `mcps_endpoints.secret_id` +carries it directly (§2.1). A provider key is bound to nothing but its provider: today it +is a freestanding vault secret discovered by scanning the project's secrets for the +provider (the SDK's settings builder does exactly this, `models.md`), and when user-owned +secrets arrive the same scan runs over the vault's own owner columns (`secrets.md`). A +generated `standard` endpoint has no row to hang a `secret_id` on in the first place — +existence itself is derived from the scan (D20) — so there is no per-endpoint fact to +record, and storing one would assert a pair nobody configures, the same cross-product +mistake the channels design refused for agents and spaces. The one place an LLM endpoint +does bind a specific secret, a custom endpoint, the binding is the same one column every +custom endpoint on either plane uses (§2.1). + +### What is deliberately not a table + +**Policy is derived, never stored.** The skeleton asked whether policy records exist. No: +every policy input already has a home — the permission catalog, the entitlement counters, +the per-endpoint configuration and tool allowlist on the endpoint rows, the static defaults +in code — and the decision is computed per call by `GatewayPolicyService` (§8). A stored +decision would need invalidation on every input; the existing two-layer entitlement pattern +(cached soft check, authoritative hard check) already answers the caching question and is +reused rather than reinvented (`policy.md`). + +**Model routes are not rows.** A generated endpoint's route is derivable: a stable prefix, +the namespace marker — `standard` (D30, §2.3) — and the provider's own +name (D20). The provider-to-models catalogue +is already a static map in the SDK (`sdks/python/agenta/sdk/utils/assets.py`, +`supported_llm_models`, eleven providers), and the API already imports the SDK for exactly +this kind of static catalogue (`core/workflows/static_catalog.py`). `core/gateways/llms/catalog.py` +wraps that map; a builtin endpoint *exists* when a `provider_key` secret exists for its +provider, and stores nothing. + +**Audit and usage are events, not tables.** One event per call into the existing events +domain (D22): `publish_event` onto the Redis stream, the `EventsWorker` behind it, the +existing query surface in front. Usage measures ride the same event's `attributes` rather +than a second write — the gateway is the only point that sees all of both planes, and +recording real usage from day one is the requirement that cannot be backfilled +(`policy.md`); the meters are a later consumer of the stream, not a schema this design +owns. §2.6 works through the shape. + +**`gateway_connections` gains nothing.** The skeleton reserved "whatever the owner +dimension implies for lookup". The owner dimension lands in the resolution *signature* now +and nowhere in storage — user-level secrets are out of scope entirely, not deferred +(`out-of-scope.md`, D10); no column changes in this scope, here or anywhere else. + +--- + +## 2. dbas + +Abstract mixins declaring columns, composed from `dbs/postgres/shared/dbas.py`. The existing +gateway domain skips the `dbas.py` file entirely — `ConnectionDBE` composes the shared mixins +directly — but that works only while a domain has one table. These domains have two each, +so each gets a `dbas.py`, per the house rule that the file exists "when needed" +(`api/AGENTS.md`). + +`ProjectScopeDBA`, `LifecycleDBA`, `IdentifierDBA`, `FlagsDBA`, `TagsDBA` and `MetaDBA` go +on every table, matching `gateway_connections`. The rest are answers to questions: + +| mixin | add it when | in these domains | +| --- | --- | --- | +| `SlugDBA` | the entity is addressed by a stable name someone types or routes on | both endpoint tables — the slug is the URL identifier (§2.3) | +| `HeaderDBA` (`name`, `description`) | a human labels it in the UI | both endpoint tables | +| `DataDBA` | there is a typed payload the columns should not fragment | both endpoint tables | +| `StatusDBA` | an attempt against the outside world can fail | both endpoint tables — the last relay/probe failure, and on an MCP endpoint the last refresh outcome too (§2.5) | + +There is no `UserScopeDBA` on the endpoint tables: custom endpoints are project-owned +configuration, and there is no owner dimension anywhere in this schema — every gateway +secret is project-owned, and user-owned *endpoints* are not designed anywhere in `v1/` +(`out-of-scope.md`). + +```python +# dbs/postgres/gateways/llms/dbas.py + +class LLMEndpointDBA( + ProjectScopeDBA, IdentifierDBA, SlugDBA, LifecycleDBA, + HeaderDBA, DataDBA, StatusDBA, FlagsDBA, TagsDBA, MetaDBA, +): + """One custom LLM endpoint: a provider deployment we reach (D19, D20).""" + __abstract__ = True + + provider_key = Column(String, nullable=True) + # String, not Enum: the provider set grows with the routing library's, and + # gateway_connections.provider_key is already a String for the same reason. + # Nullable (WP24, D34): select_upstream's `direct` branch — the one place a stored + # row's provider_key decided anything — is gone, so it decides nothing and is a label + # only; a custom row pointed at a self-hosted gateway names no provider at all. + deployment_kind = Column( + SQLEnum(LLMDeploymentKind, name="llmdeploymentkind_enum"), nullable=False + ) + # Enum: the set is ours and closed — direct, custom, azure, bedrock, + # sagemaker, vertex_ai, mock — aligned with CustomProviderKind and the runner + # wire's own `deployment` axis (services/runner/src/protocol.ts). `mock` is WP24's: + # the test double is a deployment kind, not something provider_key selects. + secret_id = Column(UUID(as_uuid=True), nullable=True) + # nullable: an endpoint with no secret is legitimate — the mock (D23), + # an unauthenticated self-hosted server. FK with SET NULL, §2.1. + # data: { route: {...}, models: {...}, settings: {...} } — §2.4 + + +# dbs/postgres/gateways/mcps/dbas.py + +class MCPEndpointDBA( + ProjectScopeDBA, IdentifierDBA, SlugDBA, LifecycleDBA, + HeaderDBA, DataDBA, StatusDBA, FlagsDBA, TagsDBA, MetaDBA, +): + """One custom MCP server: a registered upstream (D16, D19).""" + __abstract__ = True + + auth_mode = Column( + SQLEnum(GatewayAuthScheme, name="gatewayauthscheme_enum"), nullable=False + ) + # oauth | api_key | none. `none` is the whole of the first checkpoint (D23); + # `api_key` is declared but rejected by the service until the static secret + # kind exists (D14) — the enum member costs nothing, a later migration would. + secret_id = Column(UUID(as_uuid=True), nullable=True) + # the `oauth_grant` secret when auth_mode is oauth — one column, because a + # token is minted for exactly this server (D19, D3). NULL for `none`, and + # until someone connects. FK with SET NULL, §2.1; a failed refresh flips + # flags.is_valid and records the cause in status, never this column (§2.5). + # data: { route: {...}, tools: {...}, settings: {...}, oauth: {...} } — §2.4 +``` + +### 2.1 The secret reference and its constraint + +The two existing precedents disagree. `webhook_subscriptions.secret_id` is a bare nullable +UUID with no constraint (`dbs/postgres/webhooks/dbas.py`); the SSO provider row carries +`nullable=False` plus `ForeignKeyConstraint(["secret_id"], ["secrets.id"], +ondelete="CASCADE")` (`api/ee/src/dbs/postgres/organizations/dbes.py`). The webhook shape +is the older and weaker one: a dangling `secret_id` there degrades to a `log.warning` at +dispatch time, which is tolerable for a signing key and not for a gateway secret. + +Both tables take the constraint, and both choose the same delete behaviour: +`ondelete="SET NULL"`. Deleting a vault secret must not silently delete an endpoint's +configuration — the tool policy, the model allowlist, the timeouts survive, and calls fail +visibly with the needs-auth / needs-input state until someone rebinds. This is D18's +posture (a dead secret does not hide tools) applied to the row itself: secret death never +erases configuration. `CASCADE` here would let a vault cleanup — or a revocation — quietly +unregister a server, which is exactly the moment a caller needs the endpoint to stay +visible while reconnecting. + +**Why the constraint at all, given the house rule that child-to-child references are +validated in the application layer.** That rule (followed by channels, stated in its +entities document) is about *domain* children — composite-scoped references across +sibling domain tables, validated in the service rather than FK'd. The secrets table is not +a domain sibling; it is the platform vault, `secrets.id` is a plain unique primary key, and +the reference is load-bearing for a security claim. The database keeping it honest costs +one constraint. + +### 2.2 The owner dimension: in the signature now, nowhere in storage + +D10 is a signature rule: every secret lookup takes the owner from the outset, even +while the only answer is the project. What that means layer by layer, so nobody +over-applies it: + +- **The resolver takes the owner always.** `SecretsResolverInterface.resolve()` takes + the full `AuthScope` and a `SecretMode`, and the mode logic consults + `scope.user_id` (§7.2). This is the signature that is expensive to retrofit, and it is + the one thing `plan.md` says the seed must get right. +- **No table takes the owner as a key.** An endpoint's `secret_id` names one + project-owned secret and nothing here is keyed by a user — user-level secrets are out of + scope entirely, not merely unscheduled (`out-of-scope.md`). The extension, if it ever + ships, is additive: a second table per plane narrowing the answer for one member, with + no change to either endpoint table. +- **The endpoint DAOs do not grow a user key.** Custom endpoints are project configuration; + their verbs take `project_id` first and `user_id` only on writes, as authorship for + `LifecycleDBA` — the house convention. Reading D10 as "every DAO verb keys on a user" + would put a dead column on two tables. +- **The secrets table itself is untouched.** `out-of-scope.md` keeps the `(project, user)` + owner for vault rows on the table as a possible extension without putting it on any + schedule. Landing it later is a default column value, not a data migration, and no + gateway signature moves — that is the point of taking the owner now. + +### 2.3 The slug is the namespaced identifier + +D16 requires the identifier in a gateway URL to carry a namespace — an id or a slug, never +a display name. The grammar, per D30: + +```text +/gateways/mcps/builtin/agenta/{slug} builtin/agenta/tools +/gateways/mcps/builtin/composio/{integration}/{connection} builtin/composio/notion/my-notion +/gateways/mcps/custom/{slug} custom/acme-notion + +/gateways/llms/builtin/{provider}/... reserved, empty today +/gateways/llms/standard/{provider} standard/openai +/gateways/llms/custom/{slug} custom/acme-azure +``` + +**`builtin` carries a provider segment; each provider owns the grammar after it.** That is +what makes `agenta` a supplier rather than a namespace (D30): composio addresses a +connection as `{integration}/{connection}`, agenta serves its own endpoints under a bare +slug, and a third builtin provider would bring its own shape without disturbing either. + +**The segment names are the codebase's own words, and that is the justification for the +three composio segments.** `provider`, `integration` and `connection` are `provider_key`, +`integration_key` and the connection's slug — the three columns of +`gateway_connections`'s unique key, `(project_id, provider_key, integration_key, slug)`, +minus the project, which comes from the token. The brokered URL simply spells the +brokered connection's identity; naming the segments anything else would invent a second +vocabulary for one set of values (D27). + +- **`builtin`** — **our account pays**, which is the whole reason it is one namespace + (D30). Generated, never a row. Two providers today. **`agenta`**: servers we implement + and run, the mocks being its first members (D23), reached with our own minted token + (D13); the runner's loopback channel is not in this picture — it is the runner's tool + executor, not a transport, and the exclusion recorded in `notes.md` holds, with the + slice that could eventually address the gateway directly tracked as `cleanups.md` + item 5. **`composio`**: third-party servers backed by the Composio catalog the + integrations domain already consumes, the path spelling the brokered connection's + identity, the secret living at the broker behind the existing connection state + machine. On the LLM plane `builtin` is **reserved with no members today** — it is where + a model we supply the key for lands, and where metering will attach. Spelled without a + hyphen because the namespace is a path segment. +- **`standard`** — a deployment whose wire we already know, **paid for with the user's own + key** (D30). Generated, never a row. On the LLM plane: the standard-provider set (D20) + — the provider's own key is the whole identifier (`standard/openai`), the set is the + static catalogue (`core/gateways/llms/catalog.py`), and an endpoint exists when a + provider key exists for it. On the MCP plane: **reserved, empty today**. The word is + the secrets domain's own — a *standard* provider there is a standard target here, one + word meaning one thing on both sides with no mapping between them. +- **`custom`** — stored endpoints, the only rows: a customer's own deployment or reseller + on the LLM plane, a server the user brought by URL on the MCP plane. The name is the + row's `slug`, one slug, unique per project (`uq_llms_endpoints_project_slug`, + §3), validated by the shared `Slug` DTO's `URL_SAFE_SLUG` rule. + +**Identifiers are slugs or keys, never display names — and an agenta slug may be nested.** +An agenta identifier is ours, defined in code, and may carry `/` separators +(`builtin/agenta/tools/search` is one endpoint whose slug is `tools/search`), so what +follows the provider segment is a **path**, not a single component — a routing fact §9 +honours with a catch-all parameter, where composio and `custom` take fixed components. The +shared `Slug` validator governs what a *user* may type on a custom row; agenta identifiers +never pass through it, because nobody types them. + +**All three are reserved on both planes, even where one is empty.** LLM's `builtin` and +MCP's `standard` have no members today; they exist anyway, because taking a keyword costs +nothing now, while discovering later that something else claimed the segment costs a +migration of live URLs (D30). + +**The broker is named in the path, and that is deliberate** (D27). Hiding it behind a +stable name so a provider swap would not change URLs is wrong twice over: the provider is +part of a connection's identity, not an implementation detail — +that unique key above is *theirs plus ours together* — and a URL that survived a backend +swap would keep resolving while the user's tokens did not migrate, hiding a real breakage +instead of showing it. Naming the broker also lets a brokered server and a direct one to +the same vendor coexist without collision. The in-tree precedent agrees on both counts: +the tool call reference is `tools.{provider}.{integration}.{action}.{connection}`, and +the catalog routes are `/catalog/providers/{provider_key}/integrations/{integration_key}`. + +**The extra segments on the tool plane are real structure, not an inconsistency.** On the +model plane the provider *is* the server (D19), so nothing follows it. A broker is not a +server — it fronts many integrations, each through a named connection — so the one being +addressed has to be spelled out. That is D19 applied honestly to a broker. + +**There is no version segment in this grammar, on either plane.** The `/v1` an +OpenAI-compatible client sends belongs to the *upstream protocol's own path* — the client +appends `/v1/chat/completions` to whatever base it is handed, which is exactly why the +SDK's endpoint map stores `https://api.openai.com/v1` for OpenAI and a bare host for +Anthropic (`sdks/python/agenta/sdk/agents/connections/endpoints.py`). We hand out a base +ending `/v1` for that reason and no other. The tool plane has no equivalent: the whole +MCP protocol is a POST to the endpoint URL with no path after it, and the revision is +negotiated in a header (`mcp.md`). Versioning *our own* surface is a separate question +that applies to the whole API — no route in this codebase carries a version segment +today — and `contract.md` keeps it open. Nothing here invents one. + +**`builtin` aliases the standard-provider path internally, and that is fine.** The secrets +domain calls a non-custom provider *standard* (`StandardProviderKind`, +`core/secrets/enums.py`), and the LLM plane resolves against that enum; the URL says +`builtin`. The gateway maps one to the other in exactly one place — `catalog.py`, whose +functions keep the internal *standard* vocabulary (§8). **A public path segment does not +owe its spelling to an internal enum.** + +**The namespace selects the backend, which is why it earns a place in the path** rather +than in a column: a hit on `builtin/agenta` calls the Agenta tools; a hit on +`builtin/composio` calls the broker, reusing the connection the integrations domain +already brokered; a hit on `standard` calls the provider named by the segment, with the +user's own key; a hit on `custom` calls the upstream the endpoint row names, with the +secret we resolved for it (D30, §4.4). Every row is `custom` by +construction (D20 stores nothing else), so a namespace column would hold one value +forever. The DTO carries a `namespace` field stamped by the service — `CUSTOM` for rows, +the generated value otherwise — so one endpoint shape per plane serves all three +namespaces and a listing can merge them (§4). + +### What a catalog entry has to hold + +A dashboard user clicks *Notion*; they never type a URL. So something must already hold +the display name, the icon, the description and the URL — a catalog. What keeps this +design cheap is how little that is: **five fields, and that is all — name, icon, +description or category, and URL** (D27). + +**Deliberately not stored: the OAuth endpoints, and not even the scope list.** Given the +server's URL, both are fetched at configuration time with no secret at all: an +unauthenticated call returns a challenge naming the protected-resource metadata, that +names the authorization server, and the authorization server's metadata publishes its +endpoints together with the scopes it supports. So the dashboard renders real scope +checkboxes from a live call rather than from a stored field — which is exactly what +connect-time scope selection requires (D17), and it means a server rotating its +authorization endpoints never invalidates a catalog entry. + +Where the five fields come from, per namespace: + +- **`builtin/composio`, MCP plane** — the Composio catalog contract we already consume: + `CatalogIntegration` (`core/gateway/catalog/dtos.py`) carries `key`, `name`, + `description`, `categories`, `logo`, `url` and `auth_schemes`. Nothing new is curated + and nothing new is maintained; the gateway reads the same DTO the tools domain reads + today. +- **`standard`, LLM plane** — the static provider catalogue the SDK already ships + (`supported_llm_models`, §1), plus the direct base URL from the SDK's own endpoint map + (`agents/connections/endpoints.py::direct_endpoint`), which the passthrough adapter + dials; the same already-maintained-elsewhere property, from the other direction. +- **`builtin/agenta`** — in code, next to the servers themselves. +- **`custom`** — the user supplies the URL; everything else is discovered as above, and + the name and description are theirs to type on the row. + +Whether a small set of **direct** built-in servers ships alongside the Composio-backed +set — exercising our own OAuth client on purpose rather than only when a user pastes a +URL — is open in `open-designs.md` OD13. Nothing here changes either way: such servers +would be more code-defined catalog entries, the same five fields. + +Tool names are untouched downstream of this identifier: the server is distinguished by its +URL, so the proxy is transparent per server (D16) — same tool names, same schemas, same +errors. The slug-grammar precedent for *why* names must never be rewritten is already in +the tree: `apis/fastapi/tools/utils.py::parse_tool_slug` accepts two separators because +OpenAI function names forbid dots, which is the kind of accommodation a renaming gateway +would be doing forever. + +### 2.4 What is a column, and what goes in `data` + +The test is the channels one, unchanged: a field is a column when the database must act on +it — a key, a constraint, an index, a worker's `WHERE` clause. Everything else that is +typed configuration goes in `data`. Applied here: + +- **`provider_key`, `deployment_kind`, `auth_mode` are columns.** The management UI filters on + them (`query_endpoints`), and `auth_mode` decides which service paths are even legal. + They are **not the same three columns on both tables** — see below. +- **`route` is `data`.** Nothing queries it: the route lookup is by slug (§2.3), and two + endpoints pointing at one URL with different filters is legitimate, so there is no + uniqueness to enforce. It is read back whole and handed to the adapter — the exact + profile of `external_locator` in the channels design. +- **`tools` / `models` are `data`.** The filter check happens in the service with the row + already loaded; a filter is never a query predicate. +- **`settings` is `data`.** Timeouts and ceilings (D21) are read at call time off the row + in hand. Per-endpoint, only on custom endpoints — generated endpoints take the code + defaults, which is what "ours to define" means concretely. +- **`secret_id` is a column** — the FK acts on it (§2.1). +- **`expires_at` is not a column.** It lives inside the `oauth_grant` payload (§4.5), and + nothing queries it: refresh is lazy, at use time when the token is stale, so there is no + "expiring soon" worker to feed. A proactive refresh sweep, if one is ever built, promotes + it to a column then. + +### Why the two tables do not carry the same identity columns + +`llms_endpoints` has `provider_key` and `deployment_kind` and no `auth_mode`; +`mcps_endpoints` has `auth_mode` and neither of the other two. The dividing line is not +LLM versus MCP — it is **generated versus stored**. Every generated endpoint on either +plane knows its provider, because the code or the connection row that generated it says +so. A column exists only for what a *stored* row has to carry, and a stored row is always +`custom` (D20). + +**`provider_key` is a column on the LLM table, and it earns less than it looks — and after +WP24 it earns nothing at all.** Before D34 was enforced it was load-bearing in exactly two +places: `provider_key == "mock"` selected the mock adapter ahead of everything else (D23), +and on a `direct` row it decided passthrough versus translated, because that was the one +deployment where the wire's shape followed from the provider rather than from the +deployment. On the common `custom` deployment it already decided nothing — every such row +reached the passthrough adapter whatever it said. It never resolved the secret either: a +custom row resolves through `BoundSecretRef(secret_id)`, and `ProviderKeyRef` is the +`standard` arm alone. + +**Both of those are gone.** `open-designs.md` OD16 cleared nearly every provider that used +to need translation (the "expected shape" below this table was too pessimistic — see OD16's +closure), so the `passthrough`/`translated` split collapsed into one relay and `provider_key` +no longer decides which adapter a `direct` row reaches. The mock's selection moved onto +`deployment_kind == LLMDeploymentKind.MOCK`, a deployment kind rather than a provider name +— `provider_key == "mock"` was always a test-double artifact wearing a provider's clothes. + +What is left is real but modest: it is what `query_endpoints` filters on, what a listing +groups by, and what an upstream error names so the message is intelligible. That is enough +to keep a column, and not enough to call it structural — so **`NOT NULL` is gone** +(`llms_endpoints.provider_key` is nullable as of migration `oss000000022`), since a +`custom` row pointed at a self-hosted gateway is no longer made to name a provider that +means nothing to it. + +A custom MCP endpoint is a URL somebody pasted; it has no provider to name, nothing filters +on it, and no adapter choice follows from it — one protocol, one transport. + +That is also why the MCP DTO carries `provider_key` and `integration_key` as **optional, +never persisted** fields. Generated `builtin` entries populate both — `builtin/agenta` and +`builtin/composio` each name their provider, taken from the URL segment or the brokered +connection row — and every stored row leaves them null. The DTO is the union of what any +endpoint can be; the table is only what a `custom` row needs. + +**`deployment_kind` is LLM-only because only the LLM plane has more than one wire.** It +says Azure's dated API, Bedrock's signed region, or an OpenAI-compatible reseller, and it +selects the adapter. The MCP plane is one protocol over one transport — a POST to a URL — +so every `custom` row reaches `HttpMCPAdapter` and there is nothing to select. + +**`auth_mode` is MCP-only for now, and "for now" is doing real work.** Today every model +upstream authenticates one way: a secret we resolve and inject, as a header or as a request +signature. There is no OAuth against a model provider and no consent to obtain, so a column +would answer the same value on every row that has a secret, and `secret_id` already +distinguishes those from a NONE-scheme target (D23). The MCP plane needs the column because +`oauth` and `api_key` change what the gateway must *do* to obtain the secret before it can +inject anything. + +**Subscription pass-through is the thing that ends that argument** (D32). In that mode the +caller's own vendor authentication passes through untouched and the gateway injects +nothing — a second mode on the LLM plane, whatever else is true of it. + +**And it is not a column, because it is not a mode.** The data plane reads +`X-AG-Credentials` and nothing else (D31), so every other header on an inbound request is +the caller's. The relay strips ours, forwards the rest, and overwrites the provider's auth +header only when a secret resolved. Pass-through is then what happens when nothing +overwrites — no branch, no enum, no column, and it works on a generated `standard` endpoint +that has no row at all (`open-designs.md` OD15). + +What a column would still be good for is the *opposite* statement — an operator forbidding +pass-through on a target — which is a policy flag rather than a mode, and nobody has asked +for it. + +**What the two tables do share** is everything the gateway does with an endpoint rather +than to it: `slug`, `secret_id`, `data`, `flags`, `status`, and the lifecycle columns. The +identity columns differ because identity is what differs between a model provider and a +tool server. + +### The shape both planes share + +`data` is the same three keys on both tables, plus one key each plane needs alone. The +names are the same words because they mean the same thing — a reader who has read one +endpoint document can read the other: + +| key | LLM | MCP | +| --- | --- | --- | +| `route` | `base_url`, `headers`, plus `api_version`, `region`, `extras` | `base_url`, `headers` | +| the filter | `models` — `{allowlist, denylist}` | `tools` — `{allowlist, denylist}` | +| `settings` | `timeout_seconds`, `max_output_tokens` | `timeout_seconds` | +| plane-only | — | `oauth`: discovered authorization facts, cached (wave 3) | + +Four rules hold across both, and they are the whole contract: + +**One filter shape, one precedence.** An absent list is no constraint from that side; +`allowlist: []` refuses everything; `denylist` always wins over `allowlist`. Names are +matched exactly — a glob syntax would need its own decision, on both planes at once. +Nothing is filtered by default, and the `None`-versus-`[]` distinction is what keeps a +field nobody filled in from reading as *refuse everything*. Governance is expressed by +writing a filter, never by forgetting to. + +**One header slot.** `route.headers` is the only place a header can be typed. It is +addressing — the upstream will not route without it — and it is merged under the caller's +own headers so a caller cannot forge it. **It may never carry a secret**: `Authorization` +is derived from the resolved secret and overwrites whatever it sets (§7.2). The caller's own +`Authorization` and `X-AG-Credentials` are stripped before any relay on both planes — they +authenticate the caller into the gateway and belong to no upstream (D31). + +**One escape hatch, and it sits in `route`.** `route.extras` carries what a deployment needs +and no named field expresses: `vertex_project`, `aws_bedrock_runtime_endpoint`, +`aws_role_name`. It follows the header rule exactly — addressing, never secret material — +and that is what makes it safe to have. See "Why extras belongs to the route" below for why +there is no `settings.extras` and no top-level one. + +**Listing and enforcement are not the same question.** The filter says what is *allowed*; +what can be *listed* is only ever the allowlist minus the denylist, because with no +allowlist the gateway does not know the upstream's catalogue. On the MCP plane the two +never diverge in practice — the upstream answers `tools/list` and the same filter trims the +response on the way back, which is the one place the gateway rewrites a body. On the LLM +plane a `standard` endpoint's allowlist *is* the SDK catalogue, so it lists in full; a +`custom` endpoint with no allowlist relays anything and lists nothing, which is honest +rather than convenient. + +### What `llms_endpoints.data` actually holds + +`route` mirrors the runner wire's `endpoint` object field for field, so one document means +the same thing on both sides of the gateway. Which of its fields matter is decided by +`deployment_kind`, not by the field being present — an `api_version` on a Bedrock endpoint +is ignored, not an error. + +**A direct OpenAI-compatible reseller** — the common custom case. `base_url` is the whole +route; the adapter appends `/chat/completions`: + +```json +{ + "route": { "base_url": "https://api.together.xyz/v1" }, + "models": { "allowlist": ["meta-llama/Llama-3-70b-chat-hf"] }, + "settings": { "timeout_seconds": 30.0 } +} +``` + +**Azure**, where the deployment lives at a per-resource host and the API is dated: + +```json +{ + "route": { + "base_url": "https://acme.openai.azure.com", + "api_version": "2024-10-21" + }, + "models": { "allowlist": ["gpt-4o", "gpt-4o-mini"] }, + "settings": { "max_output_tokens": 4096 } +} +``` + +**Bedrock**, where there is no URL to type — the region *is* the route, and the adapter +composes the host from it: + +```json +{ + "route": { "region": "eu-central-1" }, + "models": { "allowlist": ["anthropic.claude-3-5-sonnet-20241022-v2:0"] } +} +``` + +**Vertex**, the case that earns `extras`. `vertex_location` comes from `region`, but +`vertex_project` is a GCP project id with no named field — routing, not secret material, so +it belongs here and not in the vault beside the service-account key: + +```json +{ + "route": { + "region": "europe-west4", + "extras": { "vertex_project": "acme-prod" } + }, + "models": { "allowlist": ["gemini-2.0-flash"] } +} +``` + +**A self-hosted gateway behind a routing header**, blocking one model without enumerating +the rest — the case the denylist exists for: + +```json +{ + "route": { + "base_url": "https://llm.internal.acme.io/v1", + "headers": { "X-Acme-Tenant": "research" } + }, + "models": { "denylist": ["gpt-4o"] } +} +``` + +**A generated `standard` endpoint has data too**, though nobody typed it — the catalogue +builds it (§8), and `base_url` is filled only for providers the passthrough adapter dials, +because it refuses without one. A translated provider keeps `route` empty and lets litellm +supply its own default: + +```json +{ + "route": { "base_url": "https://api.openai.com/v1" }, + "models": { "allowlist": ["gpt-4o", "gpt-4o-mini", "o1", "..."] } +} +``` + +### What `mcps_endpoints.data` actually holds + +The same document with one fewer route field and one more key. `route.base_url` is the +server's own endpoint — one URL per server (D16), and unlike the LLM plane nothing is +appended to it: the protocol is a POST to exactly that URL. + +**A plain server needing no authentication** — `auth_mode` is `none` and `secret_id` is +null; the mocks are exactly this (D23): + +```json +{ "route": { "base_url": "https://mcp.acme.io/" } } +``` + +**A server whose tools are restricted.** The allowlist does two jobs: it refuses a call to +an unlisted tool *before* the upstream is dialled, and it filters `tools/list` on the way +back: + +```json +{ + "route": { "base_url": "https://mcp.acme.io/" }, + "tools": { "allowlist": ["search", "fetch"] }, + "settings": { "timeout_seconds": 15.0 } +} +``` + +**A server with one tool withdrawn**, which is the same trim expressed the other way — +everything the server offers except the destructive one, without pinning the list to what +it offers today: + +```json +{ + "route": { + "base_url": "https://mcp.internal.acme.io/", + "headers": { "X-Acme-Tenant": "research" } + }, + "tools": { "denylist": ["delete_page"] } +} +``` + +**An OAuth-protected server**, once the connect flow has run. `oauth` is discovery +metadata cached on the row, never secret material — the token itself is an `oauth_grant` +in the vault, pointed at by the `secret_id` column (D3): + +```json +{ + "route": { "base_url": "https://mcp.notion.so/mcp" }, + "oauth": { + "resource": "https://mcp.notion.so", + "authorization_server": "https://auth.notion.so", + "scopes_offered": ["read", "write"] + } +} +``` + +**A generated endpoint's data is composed, not stored.** A `builtin/agenta` entry takes its +URL from configuration; a `builtin/composio` entry has no URL of its own at all — the +broker owns the route, and the endpoint carries a placeholder until `ComposioMCPAdapter` +lands (§8). + +### Why `extras` belongs to the route, and only there + +There are two `extras` in play and they are not the same field. The **secret's** extras +already exists (`CustomProviderSettingsDTO.extras`) and already flows: it is what carries +`aws_access_key_id`, `aws_secret_access_key`, `vertex_credentials`, `azure_ad_token` — the +material that authenticates us to a cloud. It lives in the vault, encrypted, and the adapter +merges it verbatim. + +What has no home is the **non-secret** half of the same story. A Vertex call needs +`vertex_project`; a Bedrock call may need `aws_bedrock_runtime_endpoint` or `aws_role_name`. +None of those are secrets, none has a named route field, and today the only way to deliver +one is to smuggle it into the vault alongside real secret material. That is the conflation +this design refuses: `api_version` and `region` come from the route and never from the +secret, though the legacy SDK path packs both into the secret's extras. + +So `extras` goes on `route`, for the same reason `headers` does: + +- **`route.extras` is addressing.** It answers "where and how do we dial", which is what + `route` means. It is never secret material, and the same sentence that governs + `route.headers` governs it. +- **`settings` stays ours.** `timeout_seconds` and `max_output_tokens` are governance knobs + *we* define and *we* enforce. A provider passthrough dict there would make settings half + ours and half theirs, and the category stops meaning anything. +- **A top-level `data.extras` says nothing.** A reader could not tell whether a key addresses + or configures, which is the whole reason `data` has named sub-objects at all. On a JSON + column a forward-compat bag earns nothing either — adding a named field costs no migration. + +**Precedence, which is the part that matters for safety.** The translated adapter merges in +this order: the caller's body, then `route.extras`, then the secret, then the explicit route +fields. So an endpoint can override what a caller asked for, and **the vault always outranks +the route** — a route field can never re-point authentication at a different secret. + +**The passthrough adapter ignores `extras`.** It speaks raw HTTP: a URL, headers, and a +derived `Authorization`. Provider kwargs are a routing-library concept, so there is nothing +for it to do with them. + +**Nothing equivalent on the MCP plane.** One protocol, one transport, one URL — there is no +per-provider dialect to accommodate, so `MCPEndpointRoute` stays at the shared pair. If that +ever changes it is a DTO change with no migration. + +### 2.5 Flags and status: policy state versus secret state versus attempt outcome + +Two different facts, two different homes, following the house pattern: + +- **`flags.is_active`** — an operator's switch, on both endpoint tables. Server-set default + `True`; a deactivated endpoint refuses calls with a reason that names the flag — + `GatewayEndpointInactiveError`, one type for both planes, since the flag, the refusal and + the reason are identical and only the endpoint named differs. The check sits immediately + after resolution, before the allowlist: a deactivated endpoint should not report what it + would have allowed. +- **`flags.is_valid`** — secret health, on the MCP endpoint only. Server-set, never + client-writable (the connections service already enforces exactly this: "always + server-set in flags"). A failed refresh flips it `False`; D18 then holds — the server's + tools stay listed, the call fails, the existing escalation paths offer reconnection. The + LLM endpoint carries no `is_valid`: a provider key is discovered by scanning for + existence, not bound to one row (the asymmetry above), so there is nothing per-endpoint + for a refresh to invalidate. +- **`status` (`StatusDBA`)** — the outcome of the last attempt, the shared + `{timestamp, type, code, message, stacktrace}` shape, on both tables. On an MCP endpoint + whose refresh failed: the failure that explains *why* `is_valid` is false. Otherwise: the + last relay or probe failure, which is diagnosis, not policy input. + +No lifecycle enum column exists on either table. Nothing here is a state machine: an +endpoint is configuration, and the ready / needs-auth / needs-input states are **derived** +per caller at read time — `ready` requires a valid secret for the endpoint, so it cannot be +a row fact (§4). + +### 2.6 Why usage and audit write no rows here + +D22 settles audit: one event per call into the existing events domain. Concretely, the +gateway emits through `publish_event` +(`api/oss/src/core/events/streaming.py`) with two new `EventType` members (§4), the +`EventsWorker` consumes the stream, and the existing `VIEW_EVENTS`-gated query surface +reads it back. The envelope discards a top-level user id ("events are system-generated"), +so the principal travels in `attributes` — the pattern every existing publish helper +already follows. + +Usage rides the same event rather than a second write. The two facts that cannot be +reconstructed later — the secret **owner** and the **payer** (`secret_origin`) — are +attributes of the call, exactly like the decision and the outcome; splitting them across +an audit record and a usage record would mean two writes that can disagree about the one +call they describe. The EE meters become a consumer of this stream when metering lands +(`policy.md` owns that ordering); nothing in this design writes a meter row directly, and +the legacy credits counter is left alone (D24). + +One honest caveat, inherited rather than hidden: the events stream drops writes when Redis +is unavailable and when the L1 quota check rejects, and `_safe_publish` swallows failures +by design. That is an availability posture chosen for read-analytics events; +`policy.md` flags that compliance-grade audit is not sampled and not lossy. Wave 0 keeps +D22 — one pipeline — and records the gap: if the gateways need stronger delivery than the +stream provides, the fix is in the events domain's durability, not a parallel gateway +audit table. + +--- + +## 3. dbes + +Concrete entities adding `__tablename__` and constraints. Composite primary key on +`(project_id, id)` and the project FK with `CASCADE`, as `gateway_connections` and the +triggers tables already do; the +`secret_id` constraints per §2.1. + +```python +# dbs/postgres/gateways/llms/dbes.py + +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"), + ) + + +# dbs/postgres/gateways/mcps/dbes.py + +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"), + ) +``` + +**The slug uniqueness is per project, not per provider.** `gateway_connections` scopes its +slug under `(provider_key, integration_key)` because a connection names an install of an +integration. An endpoint slug is a **URL segment** (§2.3): two endpoints with one slug +would be one route, whatever their providers, so the constraint matches the route grammar +rather than the connection precedent. + +**No unique constraint mentions `url` or `secret_id`.** Two endpoints may point at one +upstream with different tool policies, and one secret may back several endpoints (one +provider key, several custom deployments). Neither is an error, so neither is constrained. + +Deduplication and races, in the constraint-not-logic style the triggers tables set: + +| mechanism | absorbs | protects | +| --- | --- | --- | +| `(project_id, slug)` on both endpoint tables | a double-submitted create | the route grammar — one name, one endpoint | + +--- + +## 4. dtos + +Everything in this section is seed material: complete, importable Pydantic, lifted verbatim +into the seed commit (`plan.md`, wave 0). + +### 4.1 Our shared vocabulary, and the triplicate enums left where they are + +The `oauth | api_key` scheme enum and the ready / needs-auth / needs-input state machine +exist in three parallel copies today — `ConnectionAuthScheme` +(`core/gateway/connections/dtos.py`), `ToolConnectionState` plus `ConnectAffordance` +(`core/tools/dtos.py`), and `TriggerDiscoveryConnectionState` plus +`TriggerConnectAffordance` (`core/triggers/dtos.py`). The open review on this +(`open-reviews.md` OR4) gets its honest answer here: **the gateways are a separate domain +(§1), so they define their own vocabulary** — the copies below, in `core/gateways/dtos.py`, +shared by both planes. The three existing copies are **not ours to touch**: the current +scope is the gateways, agent v0, the runner and the harnesses (D15), and catalog, tools +and triggers sit outside it. Importing our vocabulary from the integrations domain would +re-couple the two through the back door for no gain; a fourth definition inside our own +boundary costs nothing and keeps the boundary real. If all four ever converge, the +neutral home is `core/shared/dtos.py` — which already holds `Identifier`, `Slug` and +`Header` — and that convergence is deliberately later work: `cleanups.md` CU9 carries +it, gated on the gateways existing at all. + +One semantic addition the existing copies lack: `NONE`. The first checkpoint's reachable +targets are unauthenticated by design (D23 — our own servers and the mocks, no OAuth, no +static kind), so the scheme enum must be able to say so. + +```python +# core/gateways/dtos.py + +class GatewayAuthScheme(str, Enum): + """How an upstream authenticates us. The gateways' own copy (OR4, §4.1).""" + OAUTH = "oauth" + API_KEY = "api_key" + NONE = "none" + + +class GatewayConnectionState(str, Enum): + """Derived per caller at read time — never stored (§2.5).""" + READY = "ready" # a usable secret exists for this endpoint + NEEDS_AUTH = "needs_auth" # OAuth target with no valid secret; connect + NEEDS_INPUT = "needs_input" # a secret must be supplied before use + + +class GatewayConnectAffordance(BaseModel): + """The call to make when a secret is missing — an interaction, not a + failure (D17). Same shape as the tools domain's ConnectAffordance.""" + endpoint: str + body: Dict[str, Any] = Field(default_factory=dict) + + +class GatewayConnectionRequirement(BaseModel): + """One target's secret state, returned from discovery and from a refused + call. `connect` is present exactly when the state is not READY.""" + target: str # the route path under the plane, per §2.3 — + # e.g. "builtin/composio/notion/my-notion" + state: GatewayConnectionState + connect: Optional[GatewayConnectAffordance] = None + + +class GatewayEndpointNamespace(str, Enum): + """The first URL segment under either plane — the same three words on both + (§2.3, D16, D30). The namespace selects the backend and says whose secret + pays, which is what earns it a place in the path.""" + BUILTIN = "builtin" # our account, so we bill: a provider segment follows + # (agenta, composio). Generated, never a row (D20, D21) + STANDARD = "standard" # a shape we know, the user's own key: the + # standard-provider set on the LLM plane, empty on MCP + CUSTOM = "custom" # a row; configurable, the user's key + + +class GatewayEndpointRoute(BaseModel): + """Where and how to dial an upstream — the two fields both planes share (§2.4). + `headers` is addressing, never a secret: Authorization is derived from the + resolved secret and overwrites whatever is set here (§7.2).""" + base_url: Optional[str] = None + headers: Optional[Dict[str, str]] = None + + +class GatewayEndpointFilter(BaseModel): + """One name filter, the same shape for LLM models and MCP tools (§2.4). + Absent list = no constraint; allowlist: [] refuses everything; denylist + always wins. Exact names only.""" + allowlist: Optional[List[str]] = None + denylist: Optional[List[str]] = None + + def allows(self, name: str) -> bool: ... # denylist first, then allowlist + def enumerate(self) -> List[str]: ... # allowlist minus denylist + + +class GatewayEndpointSettings(BaseModel): + """Per-endpoint settings, one concern for both planes (D21). Custom + endpoints only; generated endpoints take the code defaults.""" + timeout_seconds: Optional[float] = None +``` + +**One namespace enum, shared by both planes.** D30 keeps the value set identical on +purpose — the same three words, all three reserved even where one is empty — so a shared +type is the honest one, and it lives here at the domain root because the policy core's +`GatewayTarget` (§4.2) and both plane DTOs all carry it. + +**The split that matters is billing, not backend.** `builtin` is the one namespace whose +upstream we pay for, so it is the only one metering will ever attach to; `standard` and +`custom` both spend the user's own key and need no charging path at all. The vocabulary +agrees end to end: the secrets domain says *standard*, and so does the URL. + +### 4.2 The policy core's DTOs + +```python +# core/gateways/policy/dtos.py + +class GatewayPlane(str, Enum): + LLM = "llm" + MCP = "mcp" + + +class SecretMode(str, Enum): + """Declared per resolution site, not per call (`secrets.md`).""" + USER_OPTIONAL = "user_optional" # the user's if present, else the project's + USER_REQUIRED = "user_required" # the user's, or fail — never fall back + PROJECT_ONLY = "project_only" # always the project's; ignore user secrets + + +class SecretOwnerKind(str, Enum): + PROJECT = "project" + USER = "user" + + +class SecretOwner(BaseModel): + """Whose stored secret answered the lookup. Audit cannot reconstruct this + later, which is why it travels with the secret (`secrets.md`).""" + kind: SecretOwnerKind + user_id: Optional[UUID] = None # set exactly when kind is USER + + +class SecretOrigin(str, Enum): + """Whose money the call spends — the payer. `vault` is the customer's own + secret; `local` is platform-funded. Vocabulary fixed by `secrets.md`; + coordinate values with the parallel bring-your-own-secrets work, which uses + the same axis to zero-rate customer-funded usage.""" + VAULT = "vault" + LOCAL = "local" + + +# --- what the resolver is asked for ---------------------------------------- # + +class ProviderKeyRef(BaseModel): + """A standard LLM endpoint — the standard-provider set (D30): find the + provider_key secret for this provider.""" + provider_key: str + +class BoundSecretRef(BaseModel): + """A custom endpoint on either plane, or an OAuth-protected MCP endpoint: + the row already names its secret directly (§2.1).""" + secret_id: UUID + +SecretRef = Union[ProviderKeyRef, BoundSecretRef] + + +class ResolvedSecret(BaseModel): + """The (secret, owner, payer) triple (`secrets.md`). Never serialized + outward: it exists between the resolver and an adapter, in process, and no + wire model embeds it.""" + secret: SecretResponseDTO # decrypted, from VaultService + owner: SecretOwner + origin: SecretOrigin + + +# --- what policy decides, and what audit records ---------------------------- # + +class GatewayTarget(BaseModel): + """The plane-neutral description of what a call is trying to reach.""" + plane: GatewayPlane + namespace: GatewayEndpointNamespace + name: str # the last path component: a slug, a provider + # key (LLM builtin), or the connection slug + # (MCP builtin) — §2.3 + # + provider: Optional[str] = None # builtin: which supplier — agenta, composio (D30) + integration: Optional[str] = None # builtin/composio: the integration segment + endpoint_id: Optional[UUID] = None # set when the target is a row + model: Optional[str] = None # LLM plane + method: Optional[str] = None # MCP plane: the protocol method + tool: Optional[str] = None # MCP plane: the target tool, when one is named + + +class PolicyDecision(BaseModel): + allowed: bool + permission: Permission # the subject that was checked (§9) + reason: Optional[str] = None # denial cause, stable and terse; None when allowed + + +class GatewayUsage(BaseModel): + """What the meter needs, plane-neutral. Tokens on the LLM plane, calls on + both; recorded from day one even while nothing is charged (`policy.md`).""" + calls: int = 1 + input_tokens: Optional[int] = None + output_tokens: Optional[int] = None + cost: Optional[float] = None + + +class GatewayOutcome(BaseModel): + """How the call ended, for the audit event (§2.6).""" + status_code: Optional[int] = None + duration_ms: Optional[int] = None + # + usage: Optional[GatewayUsage] = None + owner: Optional[SecretOwner] = None # None when no secret was resolved + origin: Optional[SecretOrigin] = None +``` + +**Why `ResolvedSecret` carries the whole `SecretResponseDTO`** rather than a plucked +string: the payload shape differs per kind — a provider key is one string, a custom +provider is url + key + extras, an OAuth grant is a token pair — and the adapter, not the +resolver, knows which fields its upstream needs. Plucking in the resolver would grow a +per-kind switch in exactly the layer that must stay kind-agnostic. The containment rule is +behavioural, not structural: the DTO never crosses the north port, and `redaction` of it in +logs follows the runner's existing deny-set discipline. + +**Why the ref is a union and not two resolver methods.** `secrets.md` specifies *one* +function called by both planes, and the mode logic — the part that must not fork — is +identical across both lookups. Two methods would duplicate it; one method with a typed ref +keeps the owner/mode semantics in one body and makes the lookup shape data. + +### 4.3 The LLM plane + +```python +# core/gateways/llms/dtos.py + +class LLMDeploymentKind(str, Enum): + """How a provider is reached — the wire's `deployment` axis, aligned with + CustomProviderKind in core/secrets/enums.py (`models.md`: keep both axes).""" + DIRECT = "direct" + CUSTOM = "custom" # OpenAI-compatible third party or self-hosted + AZURE = "azure" + BEDROCK = "bedrock" + SAGEMAKER = "sagemaker" + VERTEX = "vertex_ai" + + +class LLMEndpointRoute(BaseModel): + """The route, mirroring the runner wire's `endpoint` object field for field + (services/runner/src/protocol.ts): apiVersion for Azure, region for AWS and + Vertex, on top of the shared base_url + headers.""" + api_version: Optional[str] = None + region: Optional[str] = None + extras: Optional[Dict[str, Any]] = None # non-secret provider knobs with no named + # field (vertex_project and friends). Same + # rule as headers: addressing, never secret; + # the secret's own extras outranks it. + + +LLMModelFilter = GatewayEndpointFilter # the plane's own name for the shared shape + + +class LLMEndpointSettings(GatewayEndpointSettings): + max_output_tokens: Optional[int] = None # the ceiling (D21). A call above it + # is REJECTED, never silently + # clamped (D25) — CeilingExceededError, §5. + # The CONFIG key. The request field it + # binds to is Chat Completions' own: + # max_tokens, or max_completion_tokens + # on reasoning models. Reading the config + # key off the body would never engage. + + +class LLMEndpointData(BaseModel): + route: LLMEndpointRoute = Field(default_factory=LLMEndpointRoute) + models: LLMModelFilter = Field(default_factory=LLMModelFilter) + settings: LLMEndpointSettings = Field(default_factory=LLMEndpointSettings) + + +class LLMEndpointFlags(BaseModel): + is_active: bool = True + # no is_valid: a provider key is discovered by scanning for existence, not + # bound to one row, so there is nothing per-endpoint for a refresh to + # invalidate (§1, "Why a provider key is a scan, not a stored binding"). + # The MCP endpoint's secret_id IS bound to one row, which is why its flags + # carry is_valid and this one does not (§2.5). + + +class LLMEndpoint(Identifier, Slug, Header, Lifecycle, Metadata): + provider_key: str + deployment_kind: LLMDeploymentKind + namespace: GatewayEndpointNamespace = GatewayEndpointNamespace.CUSTOM + secret_id: Optional[UUID] = None + # + data: LLMEndpointData = Field(default_factory=LLMEndpointData) + flags: LLMEndpointFlags = Field(default_factory=LLMEndpointFlags) + status: Optional[Status] = None + + +class LLMEndpointCreate(Slug, Header, Metadata): + provider_key: str + deployment_kind: LLMDeploymentKind + secret_id: Optional[UUID] = None + # + data: LLMEndpointData = Field(default_factory=LLMEndpointData) + flags: LLMEndpointFlags = Field(default_factory=LLMEndpointFlags) + + +class LLMEndpointEdit(Identifier, Header, Metadata): + # no provider_key, no deployment_kind: repointing an endpoint at a different + # provider family is a different endpoint, not an edit — absence makes it + # unexpressible, the channels rule + secret_id: Optional[UUID] = None + # + data: LLMEndpointData = Field(default_factory=LLMEndpointData) + flags: LLMEndpointFlags = Field(default_factory=LLMEndpointFlags) + + +class LLMEndpointQuery(BaseModel): + provider_key: Optional[str] = None + deployment_kind: Optional[LLMDeploymentKind] = None + slug: Optional[str] = None + + +class LLMCallContext(BaseModel): + """What policy needs from the request body — parsed minimally, so the body + itself can relay byte for byte (`scope-checklist.md`).""" + model: str + stream: bool = False + + +class LLMResolvedRoute(BaseModel): + """What the south port receives: the route after selection, with the model + id already in the routing library's form.""" + 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) +``` + +### 4.4 The MCP plane + +```python +# core/gateways/mcps/dtos.py + +class MCPEndpointRoute(GatewayEndpointRoute): + """Nothing beyond the shared pair: an MCP server is one URL (D16) and the + protocol POSTs to it directly — unlike the LLM plane's base_url, no path is + appended.""" + + +MCPToolFilter = GatewayEndpointFilter # the plane's own name for the shared shape + + +class MCPEndpointSettings(GatewayEndpointSettings): + """Nothing beyond the shared field yet; the subclass exists so a first + MCP-only knob is a DTO change, symmetric with the LLM side.""" + + +class MCPOAuthData(BaseModel): + """Discovered authorization facts, cached on the row. Written by the OAuth + checkpoint (WP17); absent until then. Not secret material — discovery + metadata only (D3 holds: tokens live in the vault).""" + resource: Optional[str] = None + authorization_server: Optional[str] = None + scopes_offered: List[str] = Field(default_factory=list) + + +class MCPEndpointData(BaseModel): + route: MCPEndpointRoute = Field(default_factory=MCPEndpointRoute) + tools: MCPToolFilter = Field(default_factory=MCPToolFilter) + settings: MCPEndpointSettings = Field(default_factory=MCPEndpointSettings) + oauth: Optional[MCPOAuthData] = None + + +class MCPEndpointFlags(BaseModel): + is_active: bool = True + is_valid: bool = True # server-set; flipped False by a failed refresh (§2.5). + # No LLM counterpart: a provider key is discovered by + # existence, not bound to one row (§1). + + +class MCPEndpoint(Identifier, Slug, Header, Lifecycle, Metadata): + auth_mode: GatewayAuthScheme + namespace: GatewayEndpointNamespace = GatewayEndpointNamespace.CUSTOM + secret_id: Optional[UUID] = None + connection_id: Optional[UUID] = None + # set by the service on BUILTIN entries only: the gateway_connections row + # holding the brokered account this server fronts. Our registry referencing + # theirs (§1), which is why it is not a column — builtin endpoints are + # generated, never rows, so there is nothing to store it on. Absent from + # Create and Edit: rows are custom, and custom is never broker-backed. + provider_key: Optional[str] = None + integration_key: Optional[str] = None + # BUILTIN only, with `slug` carrying the connection's slug: the three URL + # segments (§2.3) — the brokered connection's own unique key, so a listing + # renders the route without a second lookup + # + data: MCPEndpointData + flags: MCPEndpointFlags = Field(default_factory=MCPEndpointFlags) + status: Optional[Status] = None + + +class MCPEndpointCreate(Slug, Header, Metadata): + auth_mode: GatewayAuthScheme + secret_id: Optional[UUID] = None + # + data: MCPEndpointData + flags: MCPEndpointFlags = Field(default_factory=MCPEndpointFlags) + + +class MCPEndpointEdit(Identifier, Header, Metadata): + auth_mode: GatewayAuthScheme # editable: a server can move from none to + # oauth; the service revalidates secret_id + secret_id: Optional[UUID] = None + # + data: MCPEndpointData + flags: MCPEndpointFlags = Field(default_factory=MCPEndpointFlags) + + +class MCPEndpointQuery(BaseModel): + auth_mode: Optional[GatewayAuthScheme] = None + slug: Optional[str] = None + + +class MCPCallContext(BaseModel): + """What routing reads from the protocol's method and target headers — the + body is never parsed for routing (`mcp.md`, header-based routing). The + exact header names are pinned against the 2026-07-28 revision at + implementation time, in apis/fastapi/gateways/mcps/utils.py.""" + method: str + target: Optional[str] = None + + +class MCPResolvedRoute(BaseModel): + url: str + headers: Dict[str, str] = Field(default_factory=dict) + settings: MCPEndpointSettings = Field(default_factory=MCPEndpointSettings) + + +# --- the two secret mechanisms, made legible (D27) ----------------------- # + +class MCPDirectAuth(BaseModel): + """builtin/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` is + that domain's own DTO (core/gateway/connections/dtos.py), imported by + reference (§1) — no copy, no subclass.""" + connection: Connection + + +MCPRelayAuth = Union[MCPDirectAuth, MCPBrokeredAuth] +``` + +**`secret_id` is on the entity DTOs, and that is a recorded divergence from `secrets.md`.** +That document says domain responses exclude the secret *and its id*; the as-built +precedent it cites does not do this — `WebhookSubscription` carries `secret_id: Optional[UUID]` +(`core/webhooks/types.py`) and excludes only the material (`exclude={"secret"}` in +`core/webhooks/service.py`). The gateways follow the code, for a reason the stricter +sentence ignores: edits are full PUTs sourced from the freshly fetched entity, and a field +that is writable but never readable breaks that contract — every edit would silently +unbind the secret. The id is a pointer; reading the material it points at still takes +`VIEW_SECRET` through the vault. The material itself never appears on any DTO in this +document, in either direction. + +**`builtin` and `custom` are two secret mechanisms, and the shapes make that legible +at the layer where it matters** (D27). At the *entity* layer the answer is a nullable +reference, not a discriminated endpoint type: `connection_id` and `integration_key` are +stamped by the service when it generates a `builtin` entry, and a split into +`MCPBuiltinEndpoint` / `MCPCustomEndpoint` was rejected because 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. At the *south port*, where the fork is real behaviour, +the shape **is** discriminated: `MCPRelayAuth` above, one arm per mechanism, so an adapter +cannot quietly treat a brokered connection as a vault secret or vice versa +(§7.1). + +**The reference itself, designed** (D27 asks for exactly this): *which row* — the +`gateway_connections` row for the brokered account. *Keyed how* — by its own unique key, +which the URL spells outright (§2.3): the project from the token plus the +`(provider, integration, connection)` segments resolve one row through the existing +`ConnectionsService`, exactly, no chooser and no heuristics; the service stamps +`connection_id`, `provider_key` and `integration_key` onto the generated entry so nothing +downstream re-parses the path. *When the connection is revoked or invalid* — the endpoint +stays listed and derives `NEEDS_AUTH` (§8), and a relay attempt refuses with +`SecretInvalidError` before any upstream call, carrying the connect affordance for +the existing integrations flow: D18's posture, secret death never hides +configuration. + +**An explicit empty allowlist refuses; an absent one does not.** `models: {"allowlist": []}` +means no model may be called — the list was written, and it is empty. `models: {}` is a +different statement: nothing was written, so nothing is constrained. The distinction is the +whole reason the filter uses `None` rather than a default-empty list, and it is what stops +"I forgot to fill this in" from reading as "refuse everything". Standard endpoints expose +their provider's whole catalogue (the static map is the allowlist, `scope-checklist.md`); +custom endpoints declare their own or declare nothing. + +### 4.5 The two secret kinds (WP16 seed) + +Adding a kind touches exactly four places and no schema (`secrets.md`): the enum member, +the settings DTO pair, the union arm, and a branch in the hand-written +`model_validator(mode="before")` — it is manual dispatch on the sibling `kind` field, not +a discriminated union, so a missing branch rejects the kind outright. + +```python +# core/secrets/enums.py — two new members +class SecretKind(str, Enum): + ... + OAUTH_PROVIDER = "oauth_provider" + OAUTH_GRANT = "oauth_grant" + + +# core/secrets/dtos.py — the settings pairs + +class OAuthProviderSettingsDTO(BaseModel): + """Our client registration with one authorization server. The SSO kind is + the shape precedent (D14) — client id, client secret, issuer, scopes.""" + client_id: str + client_secret: Optional[str] = None # absent for public clients (PKCE-only, + # Client ID Metadata Document flows) + issuer_url: str # the authorization server + scopes: List[str] = Field(default_factory=list) + extra: Dict[str, Any] = Field(default_factory=dict) + +class OAuthProviderDTO(BaseModel): + provider: OAuthProviderSettingsDTO + + +class OAuthGrantSettingsDTO(BaseModel): + """One server's tokens. Rewritten in place on every refresh (`secrets.md`); + the endpoint's `secret_id` points here directly (§2.1).""" + access_token: str + refresh_token: Optional[str] = None + token_type: str = "Bearer" + expires_at: Optional[datetime] = None + scopes: List[str] = Field(default_factory=list) # actually granted, not requested + resource: str # the upstream server the token was minted for — tokens are + # audience-bound (`mcp.md`), so the grant names the server + +class OAuthGrantDTO(BaseModel): + grant: OAuthGrantSettingsDTO + + +# core/secrets/dtos.py — the union gains two arms +class SecretDTO(BaseModel): + kind: SecretKind + data: Union[ + StandardProviderDTO, + CustomProviderDTO, + SSOProviderDTO, + WebhookProviderDTO, + CustomSecretDTO, + OAuthProviderDTO, + OAuthGrantDTO, + ] + # plus one `elif kind == SecretKind.OAUTH_PROVIDER.value:` and one + # `elif kind == SecretKind.OAUTH_GRANT.value:` branch in + # validate_secret_data_based_on_kind, each checking its settings shape — + # the same structural checks the SSO branch performs. +``` + +**Two kinds, not one with sub-kinds** — D14's argument holds at the field level visible +above: the two share not a single field, and the sub-kind pattern in this enum +discriminates the same shape across vendors, which this is not. **The inner field is named +per kind** (`provider` for the registration, following the SSO precedent; `grant` for the +tokens, following the custom kind's freedom to pick its own noun), because calling a +token bundle a "provider" would be a lie the resolver pays for later. + +**Coordination, restated as an instruction:** the parallel bring-your-own-secrets work is +adding kinds to this same enum for sandbox providers and the tool gateway key. The enum +member names above and `SecretOrigin`'s values (§4.2) are the two points of contact; agree +both in one pass before WP16 lands (D14, `secrets.md`). + +### 4.6 The audit event types (WP4 seed) + +Two members join the existing `EventType` (`core/events/types.py`), one per plane — the +noun differs, the record does not (`policy.md`): + +```python +class EventType(str, Enum): + ... + # Gateways — one record per call, allowed or denied (D22) + GATEWAY_LLM_CALLED = "gateway.llm.called" + GATEWAY_MCP_CALLED = "gateway.mcp.called" +``` + +The attribute shape is owned by `core/gateways/policy/audit.py`, not by the enum, in the +build/publish pair every existing event family uses (`core/events/utils.py` is the +pattern: a pure attribute builder the tests hit, a publish wrapper that resolves scope, +runs the L1 soft check and never raises): + +```python +# core/gateways/policy/audit.py + +def build_gateway_call_attributes( + *, + user_id: UUID, + # + target: GatewayTarget, + decision: PolicyDecision, + outcome: GatewayOutcome, +) -> Dict[str, Any]: + """Flatten the three documents into the event's attributes map. `user_id` + goes in explicitly because the stream envelope discards top-level user ids + (§2.6); `outcome.owner` and `outcome.origin` are the two fields audit + cannot reconstruct later (`policy.md`).""" + ... + +async def publish_gateway_call( + *, + scope: AuthScope, + # + target: GatewayTarget, + decision: PolicyDecision, + outcome: GatewayOutcome, +) -> None: + """One event per call, allowed or denied. EventType by target.plane; + RequestType.ROUTER. Logs and swallows publish failures — the caller's + response never depends on the stream (the _safe_publish discipline).""" + ... +``` + +--- + +## 5. types + +Domain exceptions, in `types.py` per domain — the newer house convention +(`api/AGENTS.md`) — with the `*Error` suffix most of the codebase uses rather than the +channels design's bare names, because these classes will sit in tracebacks next to +`ConnectionNotFoundError` and `AdapterError` and should read alike. One domain base so +the router decorator can catch broadly; no HTTP status on any exception — mapping happens +at the boundary, and the tools domain's status-carrying exceptions are a habit +deliberately not copied. + +```python +# core/gateways/types.py + +class GatewaysError(Exception): + """Base exception for the gateways domain.""" + + def __init__(self, message: str = "Gateways error"): + self.message = message + super().__init__(self.message) + + +# core/gateways/policy/types.py + +class PolicyDeniedError(GatewaysError): + """The permission check refused (WP3). Carries the subject and the target so + the denial is explainable on a fixed-shape wire (§9).""" + + def __init__(self, *, permission: Permission, target: str): + self.permission = permission + self.target = target + super().__init__(f"Denied {permission.value} on {target}") + + +class EntitlementDeniedError(GatewaysError): + """The plan-level check refused. Distinct from PolicyDeniedError because + permissions and entitlements answer different questions and conflating them + is a known trap (`policy.md`).""" + + def __init__(self, *, key: str, target: str): + self.key = key + self.target = target + super().__init__(f"Entitlement {key} exceeded for {target}") + + +class SecretNotFoundError(GatewaysError): + """Resolution failed. Names WHICH owner is missing a secret, so the + caller learns whether they must connect or an administrator must + (`secrets.md`: failure is never silent and never a fallback to none).""" + + def __init__(self, *, mode: SecretMode, missing: SecretOwnerKind, target: str): + self.mode = mode + self.missing = missing + self.target = target + super().__init__( + f"No {missing.value} secret for {target} under mode {mode.value}" + ) + + +class SecretInvalidError(GatewaysError): + """A secret exists and cannot be used — revoked, or refresh failed. + Surfaces as needs_auth with a connect affordance (D17, D18).""" + + def __init__(self, *, target: str, detail: Optional[str] = None): + self.target = target + self.detail = detail + super().__init__(f"Secret for {target} is invalid") + + +class CeilingExceededError(GatewaysError): + """A governance ceiling rejects; it never silently clamps (D25). Carries the + three facts the denial must name so a caller retries correctly the first + time: the ceiling, the value asked for, and the value allowed.""" + + def __init__(self, *, ceiling: str, requested: Union[int, float], + allowed: Union[int, float], target: str): + self.ceiling = ceiling # the config key, e.g. "max_output_tokens" + self.requested = requested + self.allowed = allowed + self.target = target + super().__init__( + f"{ceiling} on {target}: requested {requested}, allowed {allowed}" + ) + + +# core/gateways/llms/types.py + +class LLMEndpointNotFoundError(GatewaysError): + def __init__(self, *, namespace: GatewayEndpointNamespace, name: str): + self.namespace = namespace + self.name = name + super().__init__(f"LLM endpoint not found: {namespace.value}/{name}") + + +class LLMModelNotAllowedError(GatewaysError): + """The model is outside the endpoint's allowlist — a custom endpoint's + declared model allowlist, or a builtin provider's catalogue (§4.3).""" + + def __init__(self, *, model: str, namespace: GatewayEndpointNamespace, name: str): + self.model = model + self.namespace = namespace + self.name = name + super().__init__(f"Model {model} not allowed on {namespace.value}/{name}") + + +class LLMUpstreamError(GatewaysError): + """The upstream failed after policy allowed. Carries the upstream status so + the proxy can relay a faithful OpenAI-shaped error (§9).""" + + def __init__(self, *, provider_key: str, status_code: Optional[int] = None, + detail: Optional[str] = None): + self.provider_key = provider_key + self.status_code = status_code + self.detail = detail + super().__init__(f"Upstream {provider_key} failed ({status_code})") + + +# core/gateways/mcps/types.py + +class MCPEndpointNotFoundError(GatewaysError): + def __init__(self, *, namespace: GatewayEndpointNamespace, name: str, + provider: Optional[str] = None, integration: Optional[str] = None): + self.namespace = namespace + self.provider = provider + self.integration = integration + self.name = name + target = "/".join(s for s in (namespace.value, provider, integration, name) if s) + super().__init__(f"MCP endpoint not found: {target}") + + +class MCPToolNotAllowedError(GatewaysError): + """The named tool is outside the endpoint's tool policy (§2.4).""" + + def __init__(self, *, tool: str, namespace: GatewayEndpointNamespace, name: str, + provider: Optional[str] = None, integration: Optional[str] = None): + self.tool = tool + self.namespace = namespace + self.provider = provider + self.integration = integration + self.name = name + target = "/".join(s for s in (namespace.value, provider, integration, name) if s) + super().__init__(f"Tool {tool} not allowed on {target}") + + +class MCPAuthRequiredError(GatewaysError): + """No usable secret on an OAuth endpoint. Carries the requirement so the + boundary can return the connect affordance instead of a bare failure (D17).""" + + def __init__(self, *, requirement: GatewayConnectionRequirement): + self.requirement = requirement + super().__init__(f"Authorization required for {requirement.target}") + + +class MCPScopeInsufficientError(GatewaysError): + """A step-up scope challenge from the upstream (D17; `mcp.md`). Raised by + the OAuth checkpoint's client; until then unreachable. Declared now so the + interaction path can be typed against it.""" + + def __init__(self, *, target: str, scopes: List[str]): + self.target = target + self.scopes = scopes + super().__init__(f"Additional scopes required for {target}: {scopes}") + + +class MCPUpstreamError(GatewaysError): + def __init__(self, *, target: str, status_code: Optional[int] = None, + detail: Optional[str] = None): + self.target = target + self.status_code = status_code + self.detail = detail + super().__init__(f"Upstream {target} failed ({status_code})") +``` + +**`PolicyDeniedError` and `SecretNotFoundError` are different failures on purpose.** +The first says *you may not*; the second says *you could, once someone connects*. The +second maps to the needs-auth / needs-input interaction path (D17) and carries enough to +build the affordance; the first never does — offering a connect affordance to a caller who +lacks the permission would be an escalation invitation. + +**`CeilingExceededError` names all three numbers, and that is what makes rejection +tolerable** (D25): a denial carrying the ceiling, the asked-for value and the allowed +value lets a caller retry correctly on the first attempt, where a silent clamp would +produce output that differs from what was asked with nothing explaining why. The +distinction D25 draws is preserved in *where* this raises: it guards **our** ceilings — +the per-endpoint config (D21) — and never second-guesses a physical limit like a model's +context window, which is the upstream's to clamp or refuse in its own shape. + +**`MCPScopeInsufficientError` is declared, not deferred.** Step-up is out of the first +increments (`scope-checklist.md` marks it detect-and-fail-visibly), but the *type* costs +nothing and lets WP8's proxy write its handler arm now, so wave 3 changes behaviour +without touching signatures. + +--- + +## 6. models + +FastAPI wire models in `apis/fastapi/gateways/{llms,mcps}/models.py`, for the **management +routers only**. The data-plane proxies have no wire models at all: their request and +response shapes belong to the OpenAI surface and the MCP transport respectively, are +relayed as bytes, and wrapping them would break every client (§1). That absence is the +router-layer split made visible in this section. + +The house triple, exactly as triggers and channels ship it — create/edit requests wrap +the core DTO under a named field, queries add `Windowing`, responses carry `count` plus +the entity: + +```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) + + +# --- connect: declared now, routed in wave 3 (WP18) -------------------------- # + +class MCPConnectRequest(BaseModel): + """Scopes the user ticked, chosen at connect time from the server's own + published metadata rather than from a stored list (D17).""" + scopes: List[str] = Field(default_factory=list) + + +class MCPConnectResponse(BaseModel): + """The authorization URL to open. The callback completes the exchange.""" + authorization_url: str +``` + +**The connect pair is declared and unrouted**, which is the shape wave 3 lands into rather +than a placeholder. What it does when it arrives: the callback writes one `oauth_grant` +secret and PUTs its id onto the endpoint. There is no +grant row to create, because the endpoint names its secret directly — the same door +`edit_endpoint` uses for every other field. + +**No create or edit request for a secret.** An OAuth endpoint's `secret_id` is written by +`edit_endpoint`, the same full PUT every other field on the row goes through — there is no +separate authorization document to forge, and the consent flow that eventually populates it +is WP17/WP18's to design against this same shape rather than a document of its own. + +--- + +## 7. daos + +Interfaces in `core/gateways/{llms,mcps}/interfaces.py`, implementations in +`dbs/postgres/gateways/{llms,mcps}/dao.py`. DAOs open their own sessions; services never +touch the engine. + +Conventions, each load-bearing: + +- **`@abstractmethod`, keyword-only after `self`**, bare `#` lines separating scope → + entity → modifiers — how every DAO in the codebase reads. +- **`project_id: UUID` first on every method.** Tenant scope is structural. +- **`user_id` on writes only**, feeding `created_by_id` / `updated_by_id`; `Optional` + where the writer is a flow rather than a person (an OAuth callback's `edit_endpoint` + call, setting `secret_id` with nobody in the loop). +- **Verb naming is `create_/fetch_/edit_/delete_/query_`**, the newer house style + (`core/workflows/`), not the connections DAO's `get_/update_`. That domain's older names + stay where they are; a new domain follows the current convention, and the divergence is + confined to one file that predates it. +- **Implementations wrap reads in `@suppress_exceptions(...)`** with + `exclude=[EntityCreationConflict]` on creates, exactly as + `dbs/postgres/gateway/connections/dao.py` does — a slug collision surfaces, everything + else degrades to `None` / `[]` / `False`. + +```python +# core/gateways/llms/interfaces.py + +class LLMEndpointsDAOInterface(ABC): + """Persistence contract for custom LLM endpoints. Standard endpoints are + generated (D20) and never pass through this interface — the service merges + them in from catalog.py, which is why nothing here has a namespace + parameter: every row is custom by construction (§2.3).""" + + @abstractmethod + async def create_endpoint( + self, + *, + project_id: UUID, + user_id: UUID, + # + endpoint: LLMEndpointCreate, + ) -> Optional[LLMEndpoint]: + """Insert. Raises EntityCreationConflict on a slug collision — the one + exception a create surfaces, per the connections DAO discipline.""" + ... + + @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 (§2.3). Backed by + uq_llms_endpoints_project_slug, so at most one row by + construction. None means the custom namespace has no such name — the + proxy 404s in the surface's own error shape (§9).""" + ... + + @abstractmethod + async def edit_endpoint( + self, + *, + project_id: UUID, + user_id: UUID, + # + endpoint: LLMEndpointEdit, + ) -> Optional[LLMEndpoint]: + """Full PUT over the editable surface (§4.3): data, flags, header, + secret_id. provider_key and deployment_kind are absent from the Edit DTO and + therefore untouchable here.""" + ... + + @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]: ... + + +# core/gateways/mcps/interfaces.py + +class MCPEndpointsDAOInterface(ABC): + """Same six verbs, same semantics, over mcps_endpoints.""" + + @abstractmethod + async def create_endpoint( + self, + *, + project_id: UUID, + user_id: UUID, + # + endpoint: MCPEndpointCreate, + ) -> Optional[MCPEndpoint]: ... + + @abstractmethod + async def fetch_endpoint( + self, + *, + project_id: UUID, + # + endpoint_id: UUID, + ) -> Optional[MCPEndpoint]: ... + + @abstractmethod + async def fetch_endpoint_by_slug( + self, + *, + project_id: UUID, + # + slug: str, + ) -> Optional[MCPEndpoint]: ... + + @abstractmethod + async def edit_endpoint( + self, + *, + project_id: UUID, + user_id: UUID, + # + endpoint: MCPEndpointEdit, + ) -> Optional[MCPEndpoint]: ... + + @abstractmethod + async def delete_endpoint( + self, + *, + project_id: UUID, + # + endpoint_id: UUID, + ) -> bool: ... + + @abstractmethod + async def query_endpoints( + self, + *, + project_id: UUID, + # + endpoint: Optional[MCPEndpointQuery] = None, + # + windowing: Optional[Windowing] = None, + ) -> List[MCPEndpoint]: ... +``` + +`None` is overloaded across these returns; the disambiguation, stated once: + +| method | `None` means | caller does | +| --- | --- | --- | +| `fetch_endpoint_by_slug` | no such custom endpoint | the proxy answers not-found in the surface's own shape | +| `edit_endpoint` | the row does not exist | 404 at the boundary | + +### 7.1 The south ports + +One port per plane, in the same `interfaces.py` files. This answers `contract.md`'s open +question — **two interfaces sharing the secret types, not one interface with two +shapes** — because the method shapes share nothing: a streaming byte relay on one side, a +single JSON round trip on the other. A merged interface would be a union with no caller. +What they share is exactly what is shared in fact: `ResolvedSecret` in, +plane-specific route and result types out. + +The result types are dataclasses, not Pydantic models, because a relay result carries an +`AsyncIterator` and lives for one call between the service and the surface — it is never +validated, stored or serialized. + +```python +# core/gateways/llms/interfaces.py + +@dataclass +class LLMRelayResult: + """One upstream answer, streaming or not. `body` yields exactly one chunk + for a non-streaming call. `usage` is populated by the adapter once `body` + is exhausted, when the upstream exposed it (the OpenAI stream carries a + trailing usage frame; the translated adapter reports the library's count); + None means unknowable, and the audit event says so rather than guessing.""" + status_code: int + headers: Dict[str, str] + body: AsyncIterator[bytes] + usage: Optional[GatewayUsage] = None + + +class LLMUpstreamInterface(ABC): + """Turns a resolved route plus a resolved secret into an upstream call. + The core never imports an implementation; wiring happens at the entrypoint.""" + + @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.""" + ... + + # async def relay_embedding(...) -> LLMRelayResult + # Deferred with the whole evaluator path (D15). Declared here as the seam it + # will occupy so nothing in the surface design forecloses it. + + +# core/gateways/mcps/interfaces.py + +@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 builtin/agenta and + custom, MCPBrokeredAuth for builtin/composio — so the two secret + mechanisms cannot be conflated by an adapter (D30). 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). + + A `custom` route's URL was typed by a user and the adapter is what + connects to it, so the outbound guard runs here before the POST: the + resolving variant in core/webhooks/utils.py, connecting to the literal + IP it returns rather than re-resolving the hostname (D28). A blocked + target is MCPUpstreamError — a transport refusal, never relayed as an + upstream body. Only `custom` strictly needs it — agenta targets are ours + and composio's are the broker's — but the adapter is reached only by + `custom`, so it runs unconditionally rather than branching on a namespace + it is never given.""" + ... +``` + +**The byte-for-byte constraint and the routing library cannot both hold everywhere, and +the port is shaped so each holds where it can.** `scope-checklist.md` marks the body +byte-for-byte as a constraint (prompt caching then works for free); `plan.md` WP7 puts the +routing library in-process. These conflict: the library takes parsed parameters and +re-serializes, which is not byte-for-byte. The resolution is the two LLM adapters in the +file tree — **`passthrough`** for upstreams that speak the caller's protocol +(OpenAI-compatible: `deployment=custom`, and direct providers whose API is +OpenAI-shaped), which relays the body untouched with only authorization injected; and +**`translated`** for providers whose wire differs and for cloud resellers whose auth is +request signing, where the library earns its place (D9) and byte-for-byte is impossible by +the upstream's own definition. A pure function in `registry.py`, +`select_upstream(provider_key, deployment) -> str`, picks the adapter key; the mocks +register under a third key. The constraint is therefore honest: byte-for-byte wherever +the protocol matches, and only there. + +**Registries copy an existing shape verbatim** — four structurally identical registry +classes already exist in catalog, connections, tools and triggers; these are the fifth +and sixth, the same shape borrowed rather than shared: + +```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]: ... + +class MCPUpstreamRegistry: + def __init__(self, *, adapters: Dict[str, MCPUpstreamInterface]): ... + def get(self, key: str) -> MCPUpstreamInterface: ... + def keys(self) -> list[str]: ... +``` + +### 7.2 The secret resolver port + +The third port, in `core/gateways/policy/interfaces.py`, implemented by +`policy/resolution.py` over `VaultService` alone (WP2) — every `SecretRef` arm resolves +through the vault directly, because the caller already has the row that names its secret +before it ever reaches the resolver (§2.1). This is the signature the seed must get right +(D10, `plan.md`): the owner is in it from the first commit, while the only answer is the +project. + +```python +# core/gateways/policy/interfaces.py + +class SecretsResolverInterface(ABC): + """One lookup, called by both planes (`secrets.md`). 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. + On an OAuth MCP endpoint this is the row's own + secret_id; SecretInvalidError when the endpoint's + flags.is_valid is False (D18), before the vault is + even read. + + Raises, never returns None: no path silently yields "no secret" + (`secrets.md`), and the exceptions carry which owner is missing so the + boundary can build the connect affordance (§5).""" + ... + + @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. + + Same scan as the ProviderKeyRef arm (provider_key + custom_provider), + returning the provider names found. Unlike resolve() it does NOT raise + when nothing matches: the empty set is the correct answer for a project + with no keys, whereas a caller reaching resolve() has already committed + to needing one.""" + ... +``` + +**Why the existence question is on this port** (R2). D20 makes a generated `builtin` endpoint +exist for a project exactly when a provider key exists for it, so `LLMGatewayService.list_endpoints` +(§8) has to ask — and its constructor has no vault dependency, deliberately. Handing it a +`VaultService` would give one service two secret seams and defeat the port; calling `resolve()` +once per provider to catch `SecretNotFoundError` is control flow by exception plus eleven vault +reads per list. Existence of a secret is a secret-layer question, so it lives with the +secret layer. + +**`builtin` deliberately never passes through this port.** Its secret lives at the +broker and never enters our vault, so there is nothing here to resolve — the MCP service +takes the brokered path instead, carrying the connection row in `MCPBrokeredAuth` (§4.4). +Routing that path through the resolver anyway, with a fourth ref arm, was rejected: it +would force `ResolvedSecret` to sometimes hold no secret, which un-types every +consumer to accommodate the one caller that has a different mechanism, not a different +lookup (D27). + +**Why the resolver is a port and not just a function.** The mode logic is pure and could +be a function; the lookups are not, and WP2's tests need the failure cases — which are the +interesting cases — without a vault. A port gives the mocks a seam (D23) and keeps +`VaultService`'s encryption-context requirement (`set_data_encryption_key`, without which +the DAO raises) inside one adapter instead of in every caller. + +**Both planes resolve with `PROJECT_ONLY` today, applied in the services** (§8): every +gateway secret is project-owned, so there is no user arm to prefer (`out-of-scope.md`). +The mode is not hardcoded in the resolver — it is an argument at the call site — which is +what keeps this a data change rather than a signature change if user-level secrets ever +ship: a call site moves to `USER_REQUIRED` or `USER_OPTIONAL` on a signature that already +accepts them. + +### 7.3 The TokenStorage adapter (WP17) + +The OAuth client is not written here — the official MCP SDK's `OAuthClientProvider` is +adopted whole (`libraries.md`), persisting through a `TokenStorage` protocol we implement. +The adapter is `core/gateways/mcps/token_storage.py`, and it is deliberately thin: **it is +the resolve-and-store glue between the SDK's protocol and the shapes this document already +defined**, not a fourth place secrets live. + +```python +# core/gateways/mcps/token_storage.py + +class VaultTokenStorage: + """Implements the pinned MCP SDK's TokenStorage protocol over the vault. + + One instance per (scope, endpoint): reads resolve through the endpoint's + own secret_id and VaultService.get_secret_by_id; writes update the + oauth_grant secret IN PLACE and, the first time, edit the endpoint row to + point secret_id at it (§2.1) — there is no separate row to touch. The + exact method set and value types come from the pinned SDK version and are + verified at implementation time (OR1) — this class's constructor is the + contract wave 0 owns.""" + + def __init__( + self, + *, + scope: AuthScope, + endpoint_id: UUID, + # + vault_service: VaultService, + mcp_endpoints_dao: MCPEndpointsDAOInterface, + mode: SecretMode = SecretMode.PROJECT_ONLY, + ) -> None: ... +``` + +Verification items the seed does not pretend to settle, all tracked in `open-reviews.md` +OR1 and OR12: the protocol's exact method set in the pinned version, that +`OAuthClientProvider` accepts this storage unchanged, that its redirect and callback +hooks wire to the dashboard connect flow rather than a local browser opener, and the +version pin itself — no MCP SDK is a dependency anywhere today, in either language. + +--- + +## 8. services + +Constructors take the DAO interfaces and the ports via keyword-only DI, never concrete +classes; cross-domain composition passes concrete service objects, which is the house rule +the composition root already enforces elsewhere (`api/entrypoints/routers.py` — every +leaf service receives the shared `connections_service` instance, and the interface rule is +enforced at the DAO and adapter seams, not between services). + +```python +class GatewayPolicyService: + def __init__( + self, + *, + resolver: SecretsResolverInterface, + ) -> None: ... + +class LLMGatewayService: + def __init__( + self, + *, + llm_endpoints_dao: LLMEndpointsDAOInterface, + policy: GatewayPolicyService, + resolver: SecretsResolverInterface, + upstream_registry: LLMUpstreamRegistry, + ) -> None: ... + +class MCPGatewayService: + def __init__( + self, + *, + mcp_endpoints_dao: MCPEndpointsDAOInterface, + policy: GatewayPolicyService, + resolver: SecretsResolverInterface, + connections_service: ConnectionsService, + upstream_registry: MCPUpstreamRegistry, + ) -> None: ... + # connections_service is required and was missing from this list until R12. + # list_endpoints resolves a builtin entry's state "through the existing + # connections service" and relay resolves a builtin target the same way, so + # the behaviour this document mandates is unreachable without it. It is a + # concrete service object by the paragraph above, not an interface — the + # rule bites at the DAO and adapter seams, not between services. +``` + +Service method signatures drop the kwarg type hints — the DTOs carry the types — following +the template's compression. The surfaces, in full: + +```python +class GatewayPolicyService: + # --- 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: + # every user has both gateways, and what entitlements will express here are + # limits, which ship with metering (D29). EntitlementDeniedError stays + # declared and mapped so that wave changes a body, not a signature. 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.6) ------------------------------------- # + + 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). + + +class LLMGatewayService: + # --- management: thin over the DAO, plus the generated merge ------------ # + + 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 standard endpoints (catalog.py, + # existing iff a provider_key secret exists for the provider — D20) plus the + # custom rows; builtin joins when it has members (D30). The only read that + # spans namespaces. Existence comes from the resolver port's + # available_provider_keys (§7.2, R2) — names only, no vault dependency on + # this constructor. + + # catalog.py — the generation, two pure functions over the SDK's static map + # (sdks/python/agenta/sdk/utils/assets.py::supported_llm_models), imported + # the way core/workflows/static_catalog.py already imports the SDK: + # + # 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, + # config at code defaults, no id and no lifecycle — it is not a row. + # None for an unknown provider. base_url comes from the SDK's direct + # endpoint map for passthrough-routed providers, which the adapter + # refuses without; translated ones keep litellm's own default. + # Secrets domain and URL now say the same word (D30, §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 (a key exists), not about the catalogue (D20).""" + + # --- the data plane (WP6, WP7) ------------------------------------------ # + + async def relay_chat_completion( + self, *, scope, namespace, name, body, headers, + ) -> LLMRelayResult: ... + + async def list_models(self, *, scope, namespace, name) -> List[str]: ... + # What backs GET /v1/models (§9, R3). Per endpoint, not global: resolve the + # target as the relay does, authorize with USE_LLM_ENDPOINTS — it is a + # data-plane read that reveals configuration — and return the allowlist: the + # static catalogue's slugs for builtin, the allowlist for custom. No secret + # resolved, no upstream called, and no new DTO: the proxy shapes the OpenAI + # list body inline, because the data plane has no wire models (§6). + + +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-namespace 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. A revoked composio connection stays listed, in + # NEEDS_AUTH — the query passes is_active=None, per D18. + + # No connect/consent verbs here. CUSTOM OAuth endpoints only (D30): + # composio servers connect through the existing integrations connect flow, + # whose state machine and redirect Composio 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 of its own. + + # --- the data plane (WP8) ----------------------------------------------- # + + async def relay( + self, *, scope, namespace, name, provider=None, integration=None, + context, body, headers, + ) -> MCPRelayResult: ... + # name is what follows the provider segment (§2.3): the agenta slug + # (possibly nested), the custom slug, or composio's connection slug — in + # which case provider and integration carry the other two segments +``` + +**The relay path, spelled once** — both planes walk the same six steps, which is D7 made +concrete; only the nouns differ. `target` here is a service-internal resolved-target +value (the generated endpoint or the row, plus which namespace answered); it never +crosses a layer, so it is not a DTO in §4: + +```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 or row; LLMEndpointNotFoundError / MCPEndpointNotFoundError + self._check_active(target) # GatewayEndpointInactiveError: + # the operator's switch, §2.5 + + context = parse_call_context(body) # model + stream; MCP reads headers + self._check_allowlist(target, context) # LLMModelNotAllowedError / + # MCPToolNotAllowedError — 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, + ) # both planes (§7.2); NONE-scheme targets skip this step; + # builtin/composio takes the brokered path instead — the connection row + # in MCPBrokeredAuth, never the resolver (§4.4, D30) — and refuses with + # SecretInvalidError when that connection is revoked, before dispatch + + result = await asyncio.wait_for( + 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), + timeout=target.config.timeout_seconds, + ) # LLMUpstreamError on expiry + + result.body = self._drain_and_record(body=result.body, scope=scope, ...) + return result +``` + +Four things in that body are deliberate: + +- **Allowlist before secret.** A refused model or tool must not cost a vault read, and + the refusal reason must be the allowlist, not a coincidental secret gap. +- **The timeout is the service's, not an adapter's.** `timeout_seconds` is a property of + the endpoint, so an adapter that forgets it must not leave the call with no ceiling at + all — which is exactly what happened while the value lived in one of three adapters. + For a stream this bounds time-to-first-byte: the proxy drains the body after this + returns, and a long legitimate stream is not a timeout. +- **Usage is recorded after the drain, on both paths.** Every adapter fills + `result.usage` while its body generator runs, and the proxy is what advances it — + so reading usage at this point would record `None` on every non-streaming call. +- **The denial is recorded before the exception leaves.** An audit trail that only records + successes answers "did every call get checked" with "every call that succeeded" — the + exact failure D1 names. +- **Usage is recorded even when the stream broke.** The record call sits after the relay + returns, but for a streamed body the outcome's usage is read off the + `LLMRelayResult` after exhaustion — the surface drains, the service records in a + `finally`. A crashed stream records what is known (`usage=None`, the status), never + nothing. + +**`list_tools`-shaped reads need no service verb.** Listing an MCP server's tools *is* a +relay (`context.method` is the list method), transparently passed through with one +asymmetry: a tool filter trims 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. The per-caller list +question from `contract.md` (shared-intermediary caching vs per-caller allowlists) is +thereby scoped: list results are cacheable per (endpoint, policy-hash), and caching is out +of the first increment anyway (`scope-checklist.md`). + +**Where the state machine is computed.** `GatewayConnectionState` (§4.1) is derived in +`MCPGatewayService` per namespace: `READY` iff the endpoint's scheme is NONE (every +`builtin/agenta` entry), or — `custom` — `secret_id` is set and `flags.is_valid` is true, +or — `builtin/composio` — the referenced connection row is active and valid, read through +the existing connections service; `NEEDS_AUTH` otherwise for an OAuth or brokered endpoint +— with the connect affordance naming the custom endpoint directly (WP17, WP18's to wire) +and the existing integrations connect flow for composio; `NEEDS_INPUT` reserved for the +api_key scheme (deferred with its kind, D14). The LLM side derives the same states from key +presence (`NEEDS_INPUT` when no provider secret exists). Nothing stores these (§2.5). + +--- + +## 9. routers + +Two router objects per plane (§1): `router.py` — the management CRUD, house rules — and +`proxy.py` — the protocol surface, whose shapes are not ours. Both live in +`apis/fastapi/gateways/{llms,mcps}/` and are mounted at the entrypoint: + +```python +# api/entrypoints/routers.py — mounts +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=mcp_gateway.router, prefix="/gateways/mcps", tags=["Gateway: MCP"]) +app.include_router(router=mcp_gateway.proxy, prefix="/gateways/mcps", include_in_schema=False) +``` + +The proxies share the plane prefix with the CRUD without collision because their first +path segment is the shared namespace enum (`builtin | standard | custom`, typed as the +path parameter so a wrong segment 422s at the router before any handler runs), and none +of those values can spell `endpoints`. + +### Permissions — the new subjects + +Six members join `Permission` (`core/access/permissions/types.py`), a triple per plane, +with `USE` as the data-plane verb following `USE_MOUNTS` — "run" belongs to things that +execute on our infrastructure; a gateway endpoint is *used*, like a mount: + +```python + # 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" +``` + +Role wiring follows the tools precedent exactly: Viewer gains the `VIEW` pair, Annotator +adds the `USE` pair (as it holds `RUN_TOOLS` today), Editor adds the `EDIT` pair. Two +triples rather than one shared set because the planes are separately governable — an +organization may let annotators call models and not reach tool servers — and per-plane +subjects are what `policy.md`'s authorization row ("may they use this server, this tool") +needs to be expressible at all. **Every member is checked by a named route below** — the +`RUN_TRIGGERS` lesson (defined, role-wired, checked by nothing) is not repeated. + +### The management CRUD + +Routes declared imperatively with `add_api_route`, every route with an `operation_id` and +`response_model_exclude_none=True`; collection routes keep their trailing slash. The LLM +block in full; the MCP block is the same shapes, one-for-one, and is elided to its table: + +```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", + ) + + # --- MCP management (MCPGatewayRouter) — same shapes, three paths total --- + # 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 (WP18, wave 3) + # GET /connect/callback (WP18, wave 3) + # + # Both are declared in §6 and NOT wired here. The callback writes the + # oauth_grant secret and PUTs secret_id through edit_endpoint above — + # the same door every other field uses. +``` + +One handler in full, house body — decorators, scope, permission, service, envelope. The +scope comes from `get_auth_scope()`, **not** `request.state` (below): + +```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) +``` + +**`AuthScope` over `request.state`.** 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` — frozen, four required UUIDs, assembled once by the auth middleware +and ContextVar-backed (`api/oss/src/utils/context.py`). The new code uses +`get_auth_scope()` exclusively: it is typed, it cannot be partially populated, it carries +`organization_id` (which `request.state` reads kept dropping and the audit event needs), +and the events domain already prefers it for exactly these reasons +(`core/events/utils.py::request_scope`). The old handlers are not rewritten here; they +converge when touched. + +**`handle_gateway_exceptions()` is written once**, in a shared +`apis/fastapi/gateways/exceptions.py`, not duplicated per router — tools and +triggers currently duplicate `handle_adapter_exceptions()` verbatim, and those domains +are out of scope to fix (D15); ours is simply written once. Mapping: `*NotFoundError` → 404, +`GatewayEndpointInactiveError` → 403 naming the flag (§2.5), +`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), `SecretNotFoundError` / `SecretInvalidError` → 409 +on the same reading — *you could, once someone connects* — `*UpstreamError` → 424, or 502 +when the upstream answered ≥500 (the 424/502 split tools and triggers already use). + +**The 409 for a missing secret holds on all three surfaces**, the two proxies included +(R11, settled). The proxies still translate into their own error *bodies* — that is the +real difference between the surfaces — but a caller branching on status gets one answer +per cause, not one per plane. + +### The data planes + +The proxies declare externally-fixed paths and **no wire models** (§6). Authentication is +the platform's own: the minted secret token travels as `Secret `, one of the three +schemes the middleware already verifies by decode alone (D13) — so the proxy handlers see +a full `AuthScope` like any other route, and nothing here is public. It arrives in +`X-AG-Credentials` when the caller sends that header, and in `Authorization` otherwise; the +dedicated header wins because on a pass-through route `Authorization` is the caller's vendor +auth and not ours to read (D31). Both are stripped before any relay, on both planes. + +**Both proxies strip the upstream's framing headers before answering** — `content-length`, +`content-encoding`, `transfer-encoding`, `connection`, `keep-alive` — through one shared +helper rather than a copy per plane. Two reasons, and the second is the one that bites: +ASGI computes those for our own response, and Starlette keeps a `content-length` it is +handed, so a relayed one outlives any body we rewrite. The MCP plane rewrites bodies +routinely — an INCLUDE tool policy filters `tools/list` — and `content-encoding: gzip` +would describe bytes httpx already decoded on our behalf. + +```python +class LLMGatewayProxy: + def __init__(self, *, llm_gateway_service: LLMGatewayService): + self.service = llm_gateway_service + self.router = APIRouter() + + # The OpenAI-compatible surface. base_url for a client is the route + # minus the protocol suffix — e.g. {api_url}/gateways/llms/standard/openai/v1. + # The trailing /v1 is the UPSTREAM protocol's own path, not our version + # segment (§2.3): the client appends /v1/chat/completions to any base it + # is handed, so the base we hand out ends /v1 and nothing else about + # this surface is versioned here. + 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", + ) + # /v1/models answers from the allowlist — the static catalogue for + # standard, the allowlist for custom — so a harness that lists before + # calling sees exactly what policy will allow. Backed by + # LLMGatewayService.list_models (§8, R3); the handler shapes the + # OpenAI list body inline, since the data plane has no wire models. + # + # "/builtin/{provider}/{rest:path}/v1/chat/completions" + # Reserved, empty today (D30) — declared when the LLM plane gains a + # provider we hold the key for, which is also where metering attaches. + # + # "/{namespace}/.../v1/embeddings" + # Deferred with the evaluator path (D15). The shape is reserved by this + # comment so nothing else claims it. + +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). One route per namespace — builtin takes a + # catch-all after its provider segment, because each provider owns the + # grammar under it: composio spells {integration}/{connection}, agenta a + # slug that may itself be nested (D30). split_builtin_path does the + # per-provider split at the boundary. + 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 thin MCP handlers both delegate to `MCPGatewayService.relay` (§8), each passing +its namespace and segments; they exist because the routes carry different path +parameters, not because the behaviour differs. + +Each proxy's `utils.py` holds the one pure function that reads the caller's request for +routing, and nothing else — both are fully unit-testable and both fail typed: + +```python +# apis/fastapi/gateways/llms/utils.py +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.""" + +# 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.""" +``` + +The proxy handlers check `USE_LLM_ENDPOINTS` / `USE_MCP_ENDPOINTS` through the same +`authorize` path (§8) — the permission lookup per call that D13 accepts as the cost of the +one-gateway-wide token, keeping a revoked permission effective immediately rather than at +the next mint. + +**Denials wear the surface's own error shape.** The LLM proxy translates the mapped +HTTP status into the OpenAI error body — `{"error": {"message", "type", "code"}}` — with +`code` carrying the stable cause (`policy_denied`, `model_not_allowed`, +`ceiling_exceeded`, `secret_missing`); 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. What it must never do is leak the house +envelope onto either surface, or swallow the upstream's own error, which passes through +untouched (D16; the pass-through scope rule in `api/AGENTS.md`). + +**Streaming rides `StreamingResponse` over `LLMRelayResult.body`**, with the audit record +written in the handler's `finally` after the iterator is exhausted (§8). A policy decision +is always made before the first upstream byte; what happens to a decision that expires +mid-stream is an open item in `architecture.md` §5 and is not silently decided here — the +stream, once begun, completes. + +### Wiring + +```python +# api/entrypoints/routers.py — construction, conditional on nothing: +# the gateways have no third-party dependency to gate on (D23) + +llm_endpoints_dao = LLMEndpointsDAO(engine=_transactions_engine) +mcp_endpoints_dao = MCPEndpointsDAO(engine=_transactions_engine) + +secret_resolver = SecretsResolver(vault_service=vault_service) +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={ + "relay": RelayLLMAdapter(), # WP24: one relay, no conversion (D34) + "mock": MockLLMAdapter(), # registered always; reachable only + }), # via the mock endpoints the local +) # stack defines (D23) + +mcp_gateway_service = MCPGatewayService( + mcp_endpoints_dao=mcp_endpoints_dao, + policy=gateway_policy_service, + resolver=secret_resolver, + upstream_registry=MCPUpstreamRegistry(adapters={ + "http": HttpMCPAdapter(), # custom: MCPDirectAuth + "composio": ComposioMCPAdapter(), # builtin/composio: MCPBrokeredAuth (D30) + "mock": MockMCPAdapter(), # serves the builtin/agenta mocks (D23) + }), +) +``` + +--- + +## 10. Retention + +The platform has no operational retention today; the channels design flagged it and +inherited it. The gateways must not inherit it **for secret material**, and mostly do +not need to, because the lifetimes fall out of the shapes above: + +- **Tokens do not accumulate.** An `oauth_grant` secret is rewritten in place on every + refresh (`secrets.md`) — there is no token history and no graveyard. Deletion is + event-driven, not scheduled: revoking access deletes the vault secret, and the + endpoint's `secret_id` follows it to NULL automatically (`ondelete="SET NULL"`, §2.1) + rather than through a second write; a **deleted project** takes everything with it + through the `CASCADE` chain — endpoint rows and the vault secrets themselves, whose + table already cascades on project. The inbound credentials retain nothing by construction: + minted, fifteen-minute expiry, never stored (D13). +- **Audit and usage records outlive what they describe, and die with the project.** They + ride the events domain (D22) and inherit its retention posture wholesale — including + the per-organization quota at ingest. Deleting an endpoint or clearing its secret does + **not** delete the events that transited it; that is what makes them an audit trail + rather than a cache, and it is why the event carries the owner and payer inline instead + of referencing rows that may be gone (§2.6). +- **Configuration is cheap and keeps itself.** Endpoint rows are small, project-scoped, + and hard-deleted by their DELETE routes; nothing here needs archival semantics, and + none is designed. + +What is deliberately not solved: a platform-wide retention policy for the events stream. +The gateways will raise its stakes — one event per model and tool call is a volume +profile the read-analytics events do not have — but the fix belongs to the events domain +(periodic, plan-configurable, as the channels design also concluded), not to a per-row +TTL invented here. diff --git a/docs/design/gateways-research/v1/libraries.md b/docs/design/gateways-research/v1/libraries.md new file mode 100644 index 0000000000..62bf5a319f --- /dev/null +++ b/docs/design/gateways-research/v1/libraries.md @@ -0,0 +1,85 @@ +# What to reuse instead of building + +The gap after `raw/existing-gateway-model.md` was "a token store with refresh, plus an OAuth +client." Most of that is already written by other people. + +## MCP OAuth client — use the official SDK + +The MCP Python SDK ships `OAuthClientProvider`, which covers essentially the whole client +side of the authorization spec: + +- the discovery chain — Protected Resource Metadata, then authorization server metadata, + with the path-aware and root fallbacks the spec requires; +- client registration by **both** mechanisms — Client ID Metadata Documents where supported, + dynamic registration as the fallback; +- PKCE on every flow; +- token refresh, with expiry tracking and automatic refresh when a token is stale but + refreshable; +- `401` handling by running discovery and authorization, and **`403` handling for step-up + when more scopes are needed** — the case flagged as open in `raw/secret-model.md`; +- persistence behind a **`TokenStorage` protocol**, so the backend is ours. + +It implements the HTTP client library's auth interface, so it drops into a normal async +client rather than requiring a bespoke transport. + +**This changes the size of OD1 substantially.** The work is not "build an OAuth broker." It +is "implement one protocol against our database, and wire two callbacks to the dashboard +connect flow." `TokenStorage` is a port and our implementation is the adapter — the same +shape as everything else here. + +Two caveats to check at implementation time, tracked in `open-reviews.md`: + +- **No MCP SDK is a direct dependency today**, in either the runner or the Python projects. + Adding one is a new dependency decision. +- Refresh-token support across MCP clients was incomplete for much of 2026, with the + TypeScript SDK landing it first and Python following. Pin a version that has it and + verify rather than assuming. + +## Token storage — the secrets service, referenced by id + +There is no token store to build. The gateways hold **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. Webhook subscriptions and SSO providers already do exactly this. + +So the `TokenStorage` implementation is thin — an adapter that reads and writes through the +secrets service rather than a persistence layer of its own. Encryption, key management, +rotation, and deletion are inherited rather than reimplemented. + +What this needs instead is **new secret kinds**, covered in `secrets.md`. + +## Model routing — the library already in the tree + +The routing and provider-adapter work for models is already handled by the multi-provider +client library the SDK depends on, and the platform-specific part is one existing function +that turns vault secrets into that library's call parameters, including the awkward +cloud-reseller secret shapes. + +Moving that function behind the gateway is the whole of it. Nothing new to adopt. + +## What was considered and rejected + +- **A dedicated integration platform as the OAuth broker.** Its actual value is a large + inventory of pre-registered OAuth applications, which is exactly what Client ID Metadata + Documents make unnecessary for MCP servers. It also deploys as a multi-service fleet with + its own datastores, under a licence that is not open source and that gates the relevant + features behind a paid tier when self-hosted. Wrong shape and wrong cost. +- **A general-purpose OAuth library.** Viable, but it would mean re-implementing the MCP + discovery chain, resource indicators, and registration selection that the official SDK + already has. Only worth revisiting if the SDK proves unusable. +- **Writing an OAuth client.** Not justified once the SDK covers discovery, registration, + PKCE, refresh, and step-up. + +## Net + +| Piece | Source | +|---|---| +| MCP OAuth client flow | official MCP SDK | +| Token persistence | existing secrets service, referenced by `secret_id` | +| Encryption, key management, rotation | existing secrets service | +| Model routing and provider adapters | existing multi-provider client library | +| `TokenStorage` adapter over the secrets service | ours — thin | +| New secret kinds | ours — an enum value, a DTO, a union arm, a validator branch | +| Secret resolution and policy | ours — the real design work | +| Audit and metering | existing pipelines | + +Only the last three rows are ours, and only one of them is design rather than wiring. diff --git a/docs/design/gateways-research/v1/mcp.md b/docs/design/gateways-research/v1/mcp.md new file mode 100644 index 0000000000..dbe42d637d --- /dev/null +++ b/docs/design/gateways-research/v1/mcp.md @@ -0,0 +1,144 @@ +# Gateways: MCP + +Everything MCP-specific. The other documents stay protocol-neutral by pushing their protocol +facts here, so that a protocol revision changes this file and not the architecture. + +**Status: protocol facts are current as of revision 2026-07-28. Gateway behaviour derived +from them is partly open.** + +## The revision we target + +**2026-07-28**, the largest revision since the protocol launched. It supersedes the revision +the earlier tool-gateway research was written against, and several of that research's +assumptions are stale as a result. + +Target this revision. The features that made a gateway expensive are precisely the ones it +removed. + +## What it removed + +- Protocol-level sessions and the session header, from the Streamable HTTP transport. +- The initialize handshake. Every request now carries its protocol version and client + capabilities in metadata; a discovery RPC replaces the handshake for negotiation. +- SSE stream resumability and message redelivery. A broken stream loses the in-flight request + and the client re-issues it with a new id. + +Servers needing cross-call state use explicit server-minted handles passed as ordinary tool +arguments. Any request can land on any instance behind a plain load balancer. + +## Three changes that are explicitly about intermediaries + +The specification names gateways as a beneficiary, which is worth taking at face value: + +1. **Header-based routing.** The method and the target name ride required HTTP headers, so a + gateway routes and authorizes without parsing the JSON body. +2. **Cacheable list results.** List endpoints now carry a freshness hint and a scope flag + controlling whether *shared intermediaries* may cache the response, and no longer vary per + connection. +3. **Multi Round-Trip Requests.** Server-initiated requests are replaced by the server + returning an input-required result that the client answers by retrying the original + request. + +The third is the one that changes our cost structure. Under the old design a gateway had to +broker a bidirectional conversation because a server could call back into the client +mid-request. Now it is plain request/response with retries — a stateless proxy rather than a +stateful broker. This is most of what makes "everything transits the gateway" affordable. + +## Authorization + +The normative position is direct: **authorization is OPTIONAL**. HTTP-transport +implementations should conform to the authorization spec when they support authorization at +all; **stdio implementations should not**, and take credentials from the environment instead. + +So a server accepting a static bearer token or API key in a header is fully within spec. +**Whether OAuth is needed is a per-server property, not a protocol-wide requirement** — which +is what makes a large class of servers nearly free to support. + +When a server *is* OAuth-protected, the client obligations are heavy and mostly +non-negotiable: OAuth 2.1 with PKCE, protected-resource-metadata discovery, resource +indicators sent on both authorization and token requests, issuer 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 identifier and the +authorization server fetches metadata from it. Pre-registration remains available. + +### The rule that decides gateway shape + +Two normative rules together: a client must not send a server any token other than one issued +by that server's authorization server, and a server must not accept or transit any other +token. + +**A gateway therefore cannot pass a caller's token upstream.** It has to be two things at +once — a resource server to its caller, validating a token minted for the gateway itself, and +an independent OAuth client to each upstream, holding its own tokens per upstream. + +This is the two-layer split, now enforced rather than merely advisable. The token-custody work +cannot be avoided by proxying, only by restricting ourselves to servers taking static +credentials. + +### Statelessness and OAuth are orthogonal + +An easy conflation worth stating: going stateless removed **protocol session** state. OAuth is +**secret lifecycle** state — expiry, refresh, step-up scopes, per-owner-per-server tokens. +This revision removes the first and leaves the second fully specified. + +The gain from statelessness is a cheaper gateway, not less authorization work. + +## Deprecations that touch us + +- **Roots, sampling and logging are deprecated**, with a minimum twelve-month window. The + suggested migration for sampling is to call a model provider directly — which in a + two-gateway world means an MCP server calling the LLM gateway. **This is the point where + the two planes touch**, and it is an argument for one policy core. +- The older HTTP+SSE transport is deprecated; Streamable HTTP is the path. +- Trace-context propagation conventions are now documented for request metadata, which lines + up with the tracing pipeline that already exists. + +## The client implementation + +Not ours to write. The official Python SDK's OAuth client provider covers the discovery +chain, both registration mechanisms, PKCE, refresh with expiry tracking, and both the +unauthorized and insufficient-scope responses — persisting behind a storage protocol we +implement over the secrets service. + +Two things to verify at implementation time, tracked in `open-reviews.md`: no MCP SDK is a +direct dependency today in either the runner or the Python projects, and refresh-token +support was still landing across SDKs during 2026, so pin a version that has it. + +## Endpoint shape — settled + +One URL per registered server, with a namespaced identifier in the path (D16). Because the +server is distinguished by its URL, **tool names pass through untouched**: the gateway is a +transparent proxy per server, not a wrapper. Same names, same schemas, same errors, same list +responses. + +The identifier is an id or a slug carrying a namespace, never a display name. A bare name +identifies nothing once servers arrive from more than one place. + +**Three namespaces, settled in D27:** `agenta` for servers we implement and run, whose first +members are the mocks; `builtin` for third-party servers shipped ready to click, backed by the +Composio catalog the integrations domain already consumes; `custom` for a server the user brings +by URL. Written without a hyphen, because the namespace is a path segment. + +**What a catalog entry holds is five fields** — name, icon, description or category, and URL. Not +the OAuth endpoints and not the scope list: given the URL, both are fetched at configuration time +with no secret, through the challenge and metadata chain above. That is what lets the dashboard +render real scope checkboxes for connect-time selection instead of storing a guess. + +This also removes the list-composition problem: each list response comes from exactly one +server, so caching is per server and a dead secret on one server cannot affect another's list. + +## Step-up — settled + +Two halves (D17). At connect time the user **selects** scopes rather than granting everything a +server advertises. At step-up the gateway raises an **interaction**, reusing the path that +already exists for a tool needing a connection that does not exist yet. + +## Open + +- **stdio servers.** The spec directs them to take credentials from the environment. Whether + we support them at all through a gateway, and where they would run, is unsettled. +- **Static-secret third-party servers.** Out of the current scope (D15), and they will need + a secret kind when they arrive (`secrets.md`). diff --git a/docs/design/gateways-research/v1/models.md b/docs/design/gateways-research/v1/models.md new file mode 100644 index 0000000000..a13bf1f15d --- /dev/null +++ b/docs/design/gateways-research/v1/models.md @@ -0,0 +1,107 @@ +# Gateways: models + +Everything model-provider-specific. The other documents stay provider-neutral by pushing +their provider facts here. + +**Status: skeleton.** The as-built facts are established; the gateway-side design is open. + +## The two axes that already exist + +The codebase already models model routing on the right two axes, and the runner wire uses the +same pair: + +- **provider** — who issued the secret. A direct-provider list and a broader list that + additionally covers cloud resellers and self-hosted deployments already exist as enums in + the secrets domain. +- **deployment** — how that provider is reached: the provider's own API, an + OpenAI-compatible third party, or a cloud reseller with its own auth scheme. + +Keep both. Collapsing them is the common mistake, and the existing enums are evidence the +distinction is load-bearing. + +## Where the routing logic lives today + +**Not where the folder name suggests.** The SDK folder named after the model client library +holds an observability callback handler. The actual routing is a single provider-settings +builder in the SDK's secrets manager: it reads vault secrets, decides whether the model is a +direct or custom provider, normalizes the model name into the library's form, and assembles +the secrets — including the cloud-reseller shapes, which each differ. + +It also guards the custom endpoint against server-side request forgery before using it. + +**Moving that function behind the gateway is the substance of the model-plane work.** Anyone +sizing it from folder names will size the wrong thing. + +## The library — settled + +The multi-provider client library the SDK already depends on handles provider adapters, +streaming differences and reseller auth schemes. That work drifts constantly, is identical for +everyone, and is not ours to own. + +**It runs in-process.** The library ships an in-process router — retries, fallbacks, load +balancing, cost tracking, callbacks — and separately a proxy server. The model plane is +therefore a **library integration, not a second deployment**. + +The split helps us. The proxy is the half that competes with our policy plane: its virtual +keys occupy the same role as our gateway token, and its per-team secret routing the same +role as our resolution modes. We take the router and own the policy, per decision D9. + +Per-request secrets are the supported in-process pattern — the key and base URL travel as +call arguments. The provider-settings builder already produces that shape. + +**One call site does not follow it.** The `llm_v0` handler assigns provider keys to +module-level attributes of the library. That is process-wide state, and in a shared gateway +process it is a cross-tenant secret leak. Converting it is a prerequisite of the move, not +a cleanup. See `open-reviews.md` OR13. + +## Embeddings are a second modality + +Two of the three SDK call sites call **embeddings**, not chat. Both use the OpenAI client +directly, read the key straight from the vault list, hardcode the provider, and bypass the +router entirely. + +**The north port therefore needs an embeddings route.** Without one these two sites cannot +transit the gateway, and decision D1 fails. They are also the least abstracted callers in the +tree, so they change the most. + +*To establish:* whether embeddings share the model registry and resolution path, or need +their own. They share the secret and the provider; they differ in request shape and in +what a meter records. + +## What the gateway removes + +The secret category that today must be held inside an agent-controlled sandbox exists only +because cloud-reseller SDKs sign requests locally rather than transmitting the secret, so +outbound substitution cannot hide it. Behind a gateway, signing happens at the gateway and +that category stops existing for gateway-routed runs. + +This is the strongest concrete security outcome in the design. + +## Callers — counted + +Four paths. See `raw/model-call-sites.md` for the full result. + +- **Agent runs** resolve a connection 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 model layer and call the provider from the workflow + process. +- **Two evaluators** call embeddings directly, in the same SDK file as the chat handler. + +They differ in who resolves the secret, where the call originates, and how they fail. All +transit the gateway, each behind its own port, and the SDK keeps its secret-fetch and +secret-injection capabilities — only the adapter behind them changes. + +The API is not a caller. It makes no model calls at all. + +## Open + +- **The north port's shape.** An OpenAI-compatible surface is the obvious choice since every + harness and the library already speak it, but harnesses that authenticate with their own + subscription login inject no secret today and may not fit. +- **Streaming and policy.** A decision has to be made before the first token; what happens to + a decision that expires mid-stream is unsettled. +- **Model aliasing and fallback.** Whether the gateway offers them at all, or stays a pure + route-and-inject layer. Offering them makes the gateway a product surface rather than an + enforcement point. +- **Spend attribution** when a call runs on a user-owned secret — see `secrets.md`. diff --git a/docs/design/gateways-research/v1/notes.md b/docs/design/gateways-research/v1/notes.md new file mode 100644 index 0000000000..3a1b2c167c --- /dev/null +++ b/docs/design/gateways-research/v1/notes.md @@ -0,0 +1,331 @@ +# Notes + +Replaced positions and observations. Read this when something in the other documents looks +wrong and you want to know whether it was already considered. + +Everything else here states only what is. History lives in this file and rationale lives in +`decisions.md`. + +--- + +## Replaced positions + +### The principal was not a blocker + +An early draft treated identity as the blocking decision, on the reading that connections are +project-scoped and therefore the platform could not attribute a call to a person. + +That conflated two separate things. **Who is calling us** is answered on every request by an +auth context carrying organization, workspace, project and user, rejected outright if any is +missing. **Which stored secret we then use** is a different lookup. The first was never in +question. + +The correction matters beyond the one point: it is why `secrets.md` treats attribution and +secret ownership as independent, and why user-level secrets need no caller-side +change. + +### Dual-mode adoption was wrong + +An early version offered "gateway and direct paths coexist, defaulting to direct." Rejected: a +governance boundary with an exception is not a boundary, and one bypass costs every claim +about policy, audit, spend and secret containment. See `decisions.md` D1. + +The related understatement — that adoption is "a resolver-side change" — was only ever true of +the runner caller, whose wire already expresses a gateway route. Every other caller is a real +change. + +### "In-process in the SDK" was the wrong frame + +An early argument preferred converting the workflow path first because it runs in-process. +Wrong regardless of which path goes first: callers depend on ports, and the adapter behind the +port talks to the gateway. The SDK keeps its secret-fetch and secret-injection capabilities; +only the implementation behind them changes. + +### A token store was invented that should not exist + +The design briefly called for a token store with its own encryption. It should never have +been: the established pattern is that a domain row carries a secret id, the secrets service +holds the value, and the consumer resolves it at use time — exactly what webhook subscriptions +and SSO providers already do. + +What survived is much smaller: two new secret kinds. See `secrets.md`. + +### The internal tool channel is not a proto-gateway + +The runner has an internal channel that delivers first-party tools to a harness over MCP. It +was briefly cited as evidence that an MCP gateway already half-exists. + +It is a separate concern — internal tool delivery, not third-party server brokering — and +conflating the two would drag its permission-rule and transport constraints into a design that +does not need them. Excluded deliberately. + +### Two "prerequisites" had the causality backwards + +An earlier revision listed two things as blockers in front of the gateway. Both are the +reverse: they are outcomes the gateway makes possible. + +**The secrets read surface.** It returns plaintext to any caller holding the view permission, +and the agent path resolves straight through it. This was written up as something to fix first. +It cannot be fixed first — callers read that route because it is how they obtain a provider key +at all. Only once everything goes through the gateway does nothing need it, and only then can it +be restricted. + +The reasoning behind the error is worth recording: it assumed the gateway's value was mainly +security, so an unsafe vault appeared to undermine it. The gateway is also governance, identity, +metering and inversion of control, none of which depend on the vault's current read behaviour. + +**Module-level provider keys in one handler.** Also written up as a blocker. That pattern exists +*because* there is no gateway and no dependency injection — a handler mutates library globals +because nothing hands it a resolved connection. It is one of the things the gateway fixes, so it +cannot gate the gateway. The handler in question is also unused and likely to be dropped, which +makes the finding moot as well as misplaced. + +**The general lesson:** when something in the current design looks like it must be fixed first, +check whether the new design is what makes the fix possible. Sequencing a fix ahead of its +enabler produces a plan that cannot start. + +### A static secret kind was proposed twice and withdrawn twice + +First proposed as a new kind, then withdrawn in favour of reusing the general-purpose custom +secret kind, then withdrawn again because existing kinds must not be overloaded — each exists +for something specific. + +The resolution is that no static kind is needed *in the current scope*, because the targets are +Agenta's own gateway and OAuth-protected servers. It returns when a third-party server +authenticating with a static token does. + +### An empty allowlist was the default, which made refusal the default + +The model allowlist was a plain list defaulting to empty, and an empty list refused every +call. The reasoning was fail-closed, and the effect was that an endpoint created without one +was dead on arrival — the dangerous state reachable by forgetting a field rather than by +writing one. + +The resolution is a filter whose lists are absent by default: `None` constrains nothing, +`[]` refuses everything, and the two are different statements. Governance is what someone +wrote, never what someone omitted. The same shape then covered the MCP tool policy, which +had the opposite default and a mode enum to express it. + +### Three escape hatches were collapsed to one, and one of the three was needed + +The endpoint document carried `route.headers`, `config.extra_headers` and a top-level +`extras`. The first two were the same thing: both adapters merged them into one outbound +dict, so the distinction was editorial. The third was read by nothing. + +Collapsing all three was right for two of them and wrong for the third. What the top-level +bag had been standing in for is real — a Vertex call needs `vertex_project`, which is +addressing, is not secret material, and has no named field. Removing the bag exposed that +the only surviving home for such a value was inside the vault, beside the service-account +key, which is exactly the secret-versus-route conflation the design rejects elsewhere. It +came back as `route.extras`, in the half of the document that means addressing. + +### The inbound credentials were confused with the upstream secret + +An early draft treated the credentials authenticating a caller *into* the gateway as a thing +needing a vault kind. They are not a secret at all — by the vocabulary the tree already uses, +they are *credentials*, Agenta's own auth. They are minted, ephemeral and never stored (D13). + +The mechanism already existed on both ends and neither was found before proposing a new one: the +access router already re-mints short-lived signed scope-carrying tokens rather than echoing an +API key, and the runner already treats its tool-callback bearer as per-turn material excluded +from the session fingerprint. + +### Step-up was designed as a failure before the existing path was checked + +An early recommendation was to request every scope a server advertises at connect time, and to +fail a call with a clear error when a step-up happened anyway. Both were wrong. + +Requesting everything removes the user's choice; the correct default is to let them select. +Failing is inconsistent with what already happens when a tool needs a connection that does not +exist — that raises an interaction with a connect affordance. Step-up is the same situation and +reuses the same path. + +### Scope crept from the callers to the whole tree + +The embeddings finding — two evaluator callers that cannot use a chat-only gateway — was +correct, and was then used to argue that the north port needed an embeddings route now. The +current scope is the gateways, agent v0, the runner and the harnesses (D15). Other services come +later, and so does that route. + +--- + +## Observations + +### The auth-scheme axis was already built + +Proposed here as new, then found implemented across three domains, along with the +ready / needs-auth / needs-input state machine and a connect affordance returned from +discovery. Both auth schemes already share one hosted-redirect flow with no secret on the +request payload. + +The lesson generalizes: this design's remaining novelty is much smaller than it first +appeared, and the reflex should be to look for the existing shape before proposing one. + +### The OAuth callback machinery is already built, twice + +Proposed here as work, then found in the tree. + +**A signed state parameter.** The connections domain already mints a server-owned, HMAC-signed +OAuth state carrying the project and the user, with a one-hour default validity, and decodes it +on the way back. The callback route is the one endpoint in the whole gateway family with no +permission check, because it authenticates on that signed state rather than on a tenant +secret, and it answers with a small HTML page that closes the popup. + +**A signed inbound webhook.** The triggers ingress verifies an HMAC over an identifier, a +timestamp and the body, with a freshness window and a replay check, then enqueues and returns +immediately. + +The MCP OAuth work therefore inherits the state signer, the unauthenticated-callback pattern and +the popup-closing response. What it does not inherit is reachability, which is why that stays +open — see `open-designs.md`. + +### The OAuth callback problem was invented, three times + +There was never a callback reachability problem, and it took three wrong framings to see it. + +**First: a firewall problem.** Wrong word. A firewall blocks inbound connections; the thing being +described was having no routable address at all. + +**Second: a private-address problem.** Closer, and still wrong, because it treated "who can reach +this deployment" as an abstract network question. + +**Third: a hosted relay** to catch redirects for deployments that could not receive them — a +public service holding other customers' authorization codes, justified by a deployment shape that +does not exist. A production web application has a domain, or nobody can log into it. + +**The question that dissolves all three:** how did the user get to the connect button? They are +looking at our interface in a browser. Whatever address served that page is an address their +browser reaches, and the authorization server never fetches the redirect target — it only sends +the browser back somewhere it has already been. + +What survives is one real constraint, and it is not about the callback: the newer +client-registration mechanism has the *authorization server* fetch a client identity document, so +an internal-only domain must register outbound instead (D26). + +**The lesson: check where the user actually is before reasoning about network topology.** Three +framings were spent on a diagram of hosts and routes when the answer was in the flow of the +person using it. + +### One flaw in that machinery, worth fixing rather than copying + +The callback path the connections service builds is hardcoded to the tool domain's mount, even +though the trigger domain creates connections through the same service. A third consumer would +make that three. The comment in the code says the public contract was kept unchanged when the +connection moved into its own domain, which explains it without justifying inheriting it. + +### The two-axis secret model came from a real gap + +"API key versus OAuth" conflates authentication method with secret ownership. Personal +access tokens are static and per-user; some OAuth grants are organizational. The existing code +has the first axis and not the second — correctly, since ownership does not yet vary. + +### Statelessness and OAuth get conflated easily + +Going stateless removed protocol session state. OAuth is secret lifecycle state. The +current protocol revision removes the first and leaves the second fully specified. The gain +from statelessness is a cheaper gateway, not less authorization work. + +### The spec now favours intermediaries + +Three changes in the current revision are explicitly about things sitting in the middle: +header-based routing, cacheable list results with a shared-intermediary scope flag, and the +replacement of server-initiated callbacks with a retry pattern. The last is what turns a +gateway from a stateful broker into a plain proxy. + +### Folder names mislead on the model side + +The SDK folder named after the model client library holds an observability callback handler. +The actual routing lives in the secrets manager's provider-settings builder. Anyone sizing the +model work from folder names will size the wrong thing. + +--- + +## Structural notes + +### The sibling is mirrored, not joined — a position that was wrong twice + +The channels design chose an existing multi-provider integration domain as its structural +sibling and copied its layout. + +**First position, wrong.** The gateways have no such sibling, because they *extend* the family +that would have been it — catalog, connections, tools and triggers. + +**Second position, also wrong.** The same reasoning survived into a draft of `entities.md`, +which put both planes inside that family on the grounds that the family is defined by outbound +brokerage behind ports and registries. + +That is structural similarity, not domain kinship, and it proves nothing: **every** domain in +this repo has ports, a registry and adapters. Judged by what it holds rather than by what it is +called, the existing family is an *integrations* domain — its contracts are integrations and +integration keys, its one table is a connections table, its consumers are tools and triggers, and +its only provider is Composio. The word "gateway" in its name means an integration gateway to +that one provider. + +Ours is traffic transiting a boundary: identity, policy, secret injection and metering, per call, +on the data path. It shares a word and nothing else. + +**Settled position.** A separate domain that mirrors the family's shape without joining it — +`gateways/` beside the existing `gateway/`, with both planes and the shared policy core inside +it. Table names deliberately do not mirror the folder path, because a name sorting beside the +connections table would read as kin. + +Two consequences. The shared auth-scheme and connection-state vocabulary is defined in our own +domain rather than unified into theirs, which would have re-coupled us through the back door; +the existing copies are out of scope (D15). And one genuine reference survives without being +evidence of kinship: a Composio-brokered MCP server points at a connection row, which is our +registry referencing theirs. + +**The general lesson: shared vocabulary is not shared domain.** Two things called gateways were +nearly merged because of a word. + +### Why there are two quarantine documents + +The channels design pushed platform facts into one document so the neutral documents would +survive platform churn. The same reasoning applies here twice over, because the two planes +churn independently: the protocol revises on its own schedule and provider APIs on theirs. One +combined document would couple them. + +### Why there is no capability-declaration document + +The channels design needed one because its adapters had genuinely different feature sets and +core had to decide what was offerable. Here the adapter differences are narrow and already +expressible — auth mode on the tool side, provider and deployment on the model side — so a +declaration layer would be ceremony. `policy.md` occupies that slot, since what actually +varies is policy rather than capability. + +Revisit if MCP server differences turn out to be wider than auth mode. + +### Why there is no out-of-process adapter contract + +The channels design needed a wire contract because third parties would implement bridges. No +equivalent need has been established here, and inventing one would create a compatibility +surface with no consumer. + +### An override that nothing exercised was wrong, and stayed wrong quietly + +Closing OD19 turned up a defect in Vertex routing that had shipped and was invisible: a row with +an explicit `base_url` skipped the `/endpoints/openapi` segment on the Chat Completions and +Responses doors, so an operator who set one — the VPC-only case the field exists for — would have +got a wrong URL on two doors out of three. Nothing caught it because nothing registers a Vertex +`base_url`: no seed, no fixture, no test. The fallback path everything does exercise composed the +segment correctly, so the tests were green and the feature was broken. + +The general shape, worth recognising again: **an optional field with no caller is not covered by +"the tests pass."** The same reasoning produced OD19 itself, where two doors disagreed about what +one stored string addressed and nothing revealed it. When a field is accepted but never supplied, +its correctness is a claim nobody has checked rather than one the suite is defending. + +--- + +## Watch list + +- Whether `policy.md` splits into two documents with little in common. If it does, + `decisions.md` D7 is wrong and the gateways should be separate systems. +- Whether the routing library is usable in-process. This decides whether the model plane is a + library integration or a service, and several packages depend on the answer. +- Whether refresh-token support is complete in the MCP SDK version we would pin. It was still + landing across SDKs during 2026. +- Whether any upstream we care about lacks Client ID Metadata Document support and forces the + deprecated registration fallback. +- Whether the deprecation of server-side sampling changes what upstream servers do, since a + server needing a model would then call the LLM gateway — the point where the planes touch. diff --git a/docs/design/gateways-research/v1/open-designs.md b/docs/design/gateways-research/v1/open-designs.md new file mode 100644 index 0000000000..da38857867 --- /dev/null +++ b/docs/design/gateways-research/v1/open-designs.md @@ -0,0 +1,741 @@ +# Open designs + +Design questions still open, with what each hinges on. Settled items move to `decisions.md`; +things tried and replaced move to `notes.md`. + +Most of this list closed in one pass. What remains needs a product call rather than an +engineering one. + +--- + +## Wave 1 rulings — surfaced by writing the package specs + +Writing the nine specs against `entities.md` found ten places the design is **silent** rather than +wrong. None is a contradiction. Four change a signature the seed freezes and therefore must be +settled before the seed is written; the rest can be settled during wave 1. + +### Settled before the seed — all four + +**R1. `apis/fastapi/gateways/exceptions.py` has no owner. → The seed owns it.** Both proxies and +the CRUD routers need `handle_gateway_exceptions()`, so no single package can. It moves into the +seed like the DTOs, because shared infrastructure with three consumers is exactly what the seed is +for. The ownership table says so. + +**R2. `LLMGatewayService`'s frozen constructor takes no vault dependency**, yet `list_endpoints` +must decide which generated endpoints exist, which under D20 means "those a key exists for". +**→ The resolver port gains one method; the service gains no dependency.** + +```python +@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 (D20).""" +``` + +Handing the service a `VaultService` would give it two secret seams and defeat the port. The +question "does a key exist for this provider" is a secret-layer question, and the resolver is +the secret layer. The alternative — calling `resolve()` eleven times and catching +`SecretNotFoundError` — is control flow by exception and eleven vault reads per list. + +Three packages gain a line: **WP2** implements it in `resolution.py`, **WP5** implements it in the +mock resolver from its dict, **WP7** calls it from `list_endpoints`. + +**R3. `GET /v1/models` has no backing service method. → `list_models`, on the data-plane half of +`LLMGatewayService`, returning the allowlist.** + +```python +async def list_models(self, *, scope, namespace, name) -> List[str]: ... +# Resolves the target, authorizes with USE_LLM_ENDPOINTS, and returns what +# policy will allow: the static catalogue's slugs for builtin, the allowlist for +# custom. No new DTO — the proxy shapes the OpenAI list body inline, as it has +# no wire models (§6). +``` + +It is per endpoint, not global — the route is `/{namespace}/{name}/v1/models` (§9). Owned by +**WP7**, called by **WP6**, exactly like `relay_chat_completion`. + +**R4. `GatewayPolicyService.record()` sits on the C1 hot path, but its real body is a +wave 2 file. → WP3 ships it as a no-op that returns `None` and never raises.** + +Note this is not actually a seed file: `core/gateways/policy/service.py` belongs to **WP3**, and the +seed carries only DTOs, types and interfaces. What the seed freezes is the *call*, which every wave +1 relay makes unconditionally. So wave 2 changes a body, never a call site — and no relay path can +be broken by an audit sink that does not exist yet. + +### Can be settled during wave 1 + +**R5. The gateway's entitlement key does not exist. → It should not. Settled as D29: no entitlement +gate in wave 1.** Every user has both gateways, so the check would ask a question with one answer. +What entitlements will express here are *limits*, and a limit cannot be enforced before anything is +measured — so it ships with usage metering and billing, which `scope-checklist.md` already defers +together for the same reason. WP3 writes the permission check only; no placeholder key, because a +placeholder that always permits is something a later reader mistakes for enforcement. +`EntitlementDeniedError` stays declared and mapped, so the wave that adds limits changes a body +rather than a signature. + +**R6. `PolicyDecision.reason` has no fixed vocabulary** beyond "stable and terse". Three packages +would otherwise each invent their own strings, and the audit attributes and the boundary's error +map both key off it. **Settled at kickoff by adopting WP3's two:** `"permission_denied"` and +`"entitlement_denied"` — the only two failure modes `authorize()` produces. WP4's audit attribute +builder and WP10's exception mapping read these verbatim rather than each choosing. A third value +needs a decision here, not a commit. + +**R7. No SSRF guard was assigned for the gateway's own outbound relay** to a user-supplied custom +MCP server URL — the one item on this list that was a security gap rather than an unstated detail. +**Settled as D28: reuse `core/webhooks/utils.py`, call it at both ends.** Registration (**WP10**) +calls the no-DNS gate `validate_url_format_and_literal_ip`; relay (**WP8**) calls +`resolve_validated_webhook_ip` and connects to the literal IP it returns, keeping the `Host` header +and `sni_hostname` on the original name — the pinning `core/webhooks/delivery.py` already +demonstrates. Two refinements come from the runner's sibling guard: a host allowlist so a +self-hoster can permit one internal server without disabling the guard, and a distinct message for +"could not be resolved" so a DNS typo does not read as a security rejection. + +The catch that makes this more than paperwork: `AGENTA_INSECURE_EGRESS_ALLOWED` defaults to `true` +and is set in no deployment configuration in this repo, so today the guard is inert everywhere it +runs. C1 verifies with it `false`, and setting it `false` on shared deployments is a +named action. + +**R8. The Composio-backed MCP adapter has no owning package in wave 1** — and on inspection it +should not, because C1's reachable targets are our own servers and the mocks (D23). It +belongs to whichever wave first makes a brokered server reachable. Worth stating so its absence +reads as intent rather than omission. + +**R9. `litellm` is not a direct dependency of the API**, only transitive through the SDK package. +If routing runs in the API process, that service declares it (`raw/model-call-sites.md` notes the +same thing). + +**R12. `MCPGatewayService`'s frozen constructor omitted `connections_service`** — surfaced by +building it. §8 mandates that `list_endpoints` resolve a builtin entry's state *"through the +existing connections service"*, and `relay` resolve a builtin target the same way, so the +behaviour the document requires cannot be written from the listed dependencies. **Settled: the +constructor gains it**, as a concrete service object — §8's own paragraph says cross-domain +composition passes concrete services and that the interface rule bites at the DAO and adapter +seams, not between services. §8 now lists it. + +This is the same class of gap as R2, and the two were settled differently on purpose. R2's +question was *does a secret exist*, which is the secret layer's own question, so it became +a method on the port rather than a second dependency. R12's is *what does the integrations domain +say about this connection*, which no port of ours can answer. The blast radius is one line in the +composition root: the proxies and routers receive the service, they do not construct it. + +**R13. The seed put the two upstream registries in `interfaces.py` as well as `registry.py`.** +§7.1 presents the south ports and their registries in one code block headed `interfaces.py`, so +the transcription carried the registry classes there; but §0's file layout is explicit — +`interfaces.py` holds *"the DAO interface + the south port"* and `registry.py` holds *"adapter key +-> interface"*. The result is two classes of each name, the `interfaces.py` pair being +never-implemented stubs that would win silently if imported. **The stubs come out at the merge**, +leaving the real ones in `registry.py`. Deferred to IM2 rather than fixed mid-flight, because the +packages that own `registry.py` were still writing when it was found. + +**R11. §9's exception-mapping table is narrower than §5's exception set** — surfaced by writing +the seed. The table names six categories; `SecretNotFoundError`, `SecretInvalidError` and +`MCPScopeInsufficientError` are not among them, and a fall-through would answer a project with no +provider key with a 500, on C1's hot path. + +**Mapped to 409 in the seed, on §5's own words** rather than on invention: "the second says *you +could, once someone connects* … maps to the needs-auth / needs-input interaction path (D17)", which +is the same interaction status `MCPAuthRequiredError` already takes. `SecretInvalidError` +follows D18 identically. Confirm before C1; a different status is a one-file change. + +**SETTLED at 409, on all three surfaces.** The CRUD decorator and the MCP proxy already agreed; +the LLM proxy was the outlier at 404, and its justifying comment claimed the decorator gave 404 +too, which it never did. A caller branching on status was told "not found" — permanent — for a +state that resolves the moment someone connects a key, and got a different answer from each +plane for one failure. The two surfaces still carry different error *bodies*, which is the real +distinction; the status is now the same. + +**R10. Two small resolution behaviours are undefined:** the tie-break when two secrets of the same +kind match one provider, and whether resolution validates that a grant reference's endpoint is +actually OAuth-protected. + +--- + +## Open + +### OD10. What is in the first increment of each gateway — CLOSED, overtaken + +Answered by two shipped waves rather than by argument. C1 stood both gateways up; C2 made them the +only way out. The work-package list this design said it blocked exists, has been executed twice, +and is now planned a third time in `workstreams/launch-3.md`. The original question, for the +record: + +Both gateways are being built. What is open is what each one does first, and the checklist in +`scope-checklist.md` is where that gets marked. + +The MCP side has a shape: **the first checkpoint has no OAuth**, and OAuth becomes its own +checkpoint carrying consent, step-up and callback reachability together — the last one so it can +be tested in development at all. With the static secret kind also deferred, the first +checkpoint's reachable targets are our own servers and the mocks (D23), which is a complete set +rather than a gap. + +**Blocks the work-package list**, which cannot be sequenced without it. + +### OD13. Does a set of direct built-in MCP servers exist from the start — CLOSED + +**Yes, and through Composio rather than a curated direct set**, for as long as Composio's own +terms permit including its servers under `builtin`. That answers the shipping question without +taking on the per-server catalogue this design was weighing, so the maintenance argument against +it does not apply. + +**This is the namespace split working, not a gap in it.** Composio brokers the authorization for +`builtin`; our own OAuth client is what authorizes a `custom` server — one a user brings by URL. +The two are different suppliers for different namespaces, so "our client is only reached through +`custom`" is its purpose rather than a shortfall in coverage. + +The original question, for the record: + +`builtin` means Composio-backed (D27), so a user clicks an icon and never types a URL, and nothing +new is curated. The open part is whether a **small set of servers we reach directly** ships +alongside it, or waits. + +**Why it might not wait.** With `builtin` meaning only Composio, our own OAuth client is exercised +by nothing except a server a user pastes in by hand, which is the least-travelled path and the one +least likely to be exercised before a customer hits it. Shipping a handful of direct servers is how +that code gets used on purpose rather than by accident. It is also the difference between owning +the vendor relationship and reselling one. + +**Why it might.** It is the only part of the built-in story that carries ongoing maintenance. + +**The maintenance is smaller than it looks, and the pattern is already in the repo.** Only five +fields per server are stored — name, icon, description or category, and URL — because the OAuth +endpoints and the supported scopes are fetched from the server itself at configuration time +(D27). The URLs can be generated from the official public registry, which publishes name, URL and +description. Icons need not be curated either: an openly licensed brand-icon set covers a few +thousand vendors as plain files with no API call, though its coverage of the vendors we want is +unverified. + +The refresh mechanism exists already, for the model catalogue: a large generated data file next to +small hand-curated ones, plus a skill carrying the generator script. An MCP server catalogue is +the same shape at a fraction of the size — realistically twenty to forty entries, the servers +people actually ask for, not a connector marketplace. + +**Recommendation:** ship a small direct set, for the reason above rather than for coverage. Its +size is a product call. + +### OD14. Which harnesses can carry a second identity signal without losing their vendor login — CLOSED (WP13 phase 0) + +D32 settles that subscription pass-through is a real funding shape and why it cannot be a +namespace. What it cannot settle is whether any given harness can actually be configured for it, +because that is a fact about releases, not about design. + +**Correction to this document's own harness list.** OD14 as originally written named "Codex, +Claude Code, OpenCode." OpenCode is not a harness this codebase drives — there is no OpenCode +package, adapter, or ACP bridge anywhere in the tree (`grep -ri opencode` outside this document +and specs-wp13.md finds nothing). The runner's actual third harness, alongside Claude Code and +Codex, is **Pi** (`@earendil-works/pi-coding-agent`, ACP agent id `"pi"`, wire harness ids +`pi_core`/`pi_agenta`) — confirmed against `services/oss/src/agent`'s `HarnessType` enum +(`sdks/python/agenta/sdk/agents/dtos.py`: `PI`/`CLAUDE`/`AGENTA`/`CODEX`, no `OPENCODE` member) +and the runner's own `acpAgent` mapping (`run-plan.ts`). The matrix below is run against Pi, +Claude Code and Codex — the harnesses that exist — not OpenCode. + +Two things must be simultaneously true per harness: it sends `X-AG-Credentials` on model +requests, **and** pointing its base URL at us does not make it abandon its vendor subscription +login in favour of an API-key path. The second is the one that quietly fails — a harness that +treats a custom base URL as "the user configured a raw API endpoint" will stop sending the +subscription session entirely, and the symptom is an auth error from the vendor, not from us. + +**The matrix, run against the pinned releases in `services/runner/package.json`:** + +| Harness | Release | Custom header + base-URL override | Subscription survives base-URL override | +| --- | --- | --- | --- | +| Pi | `@earendil-works/pi-coding-agent@0.80.6` (`pi-ai@0.80.6`) | **Yes.** `models.json`'s provider config carries `headers: Record` alongside `baseUrl`, first-class (bundled `docs/models.md`/`docs/custom-provider.md`, "Custom Headers" section). Verified directly in the pinned package's own bundled docs, not inferred. | N/A — the header rides a NEW provider entry (named after the connection slug); it does not touch the operator's own OAuth-provider entries, so there is no login to lose. | +| Claude Code | `@agentclientprotocol/claude-agent-acp@0.58.1` | **Yes.** `ANTHROPIC_CUSTOM_HEADERS` (newline-separated `Name: Value` pairs) alongside `ANTHROPIC_BASE_URL`. Verified by reading the pinned bridge's own compiled source (`dist/acp-agent.js`, `createEnvForGateway`): it sets exactly this pair for its own `"gateway"` ACP method, so this is a mechanism the pinned release already exercises, not a guess. | **No.** The same function sets a placeholder `ANTHROPIC_AUTH_TOKEN` "to bypass claude login requirement" whenever it builds this env — overriding the base URL forces the API-key-shaped path; the underlying Claude Code SDK does not keep sending the subscription session once `ANTHROPIC_BASE_URL` is set. | +| Codex | `@openai/codex@0.145.0` / `@agentclientprotocol/codex-acp@1.1.7` | **Yes.** A custom `[model_providers.]` table in `config.toml` supports `base_url`, `env_key` (bearer token, indirection via an env var name) and `env_http_headers` (arbitrary header name -> env var name). Cross-checked against this repo's own prior Codex-harness research (`docs/design/codex-harness/decisions.md` D-002: "codex 0.145 supports a custom model provider with `env_key`... the WebSocket-upgrade caveat disappears (custom providers do not attempt it)") and codex-rs's public `ModelProviderInfo` struct. | **No, but moot.** Subscription mode authenticates from the BUILT-IN provider's mounted OAuth login exclusively (this repo's own D-002 ruling: "Subscription mode is unchanged (the operator's own login file via symlink)"); a custom `model_providers` entry with `base_url` is a structurally separate, mutually exclusive provider selection. There is no run that overrides the base URL and also expects the built-in login to answer. | + +**Conclusion: no harness fails the matrix for wave 2's own need.** All three carry a custom +header alongside a base-URL override, which is all `credentialMode: "none"` (the gateway route) +needs. None of the three lets a base-URL override coexist with a preserved subscription login — +but wave 2 does not build subscription pass-through (D32, explicitly deferred) and never asks a +harness to combine the two, so this is not a wave-2 blocker. It IS the exact fact D32's own text +predicted would be needed before pass-through could be built, and it is now recorded for whoever +picks that up. + +**The fallback if a harness fails the matrix** is the local-agent shape: a small local process +between harness and gateway that holds the gateway identity and leaves the harness's own vendor +login untouched. Not needed for wave 2 — no harness failed the matrix for wave 2's actual +requirement (header + override, no subscription combination attempted). + +### OD15. Pass-through is not a mode at all — it is the default when nothing overwrites — CLOSED + +Settled as written below: there is no mode to store, because pass-through is what already happens +when the gateway has no secret to inject. + +The question was where a pass-through target keeps its mode, given that pass-through's +natural targets are `standard` endpoints, which are generated and have no row (D20). It +keeps it nowhere, and there is no mode to keep: **it is what already happens when the +gateway has no secret to inject.** + +**The rule, in full:** + +- The data plane reads `X-AG-Credentials` and nothing else (D31), so every other header on + an inbound request belongs to the caller. +- The relay strips `X-AG-Credentials` and forwards the rest. +- If the endpoint resolves a secret, the adapter overwrites that provider's own auth header + with it. If it does not, whatever the caller sent stands and reaches the upstream. +- Everything else is unchanged. The target must still resolve and be active, the model + filter still applies, the ceiling still applies, and the audit event still fires with + `secret_origin` recording that no secret of ours paid. + +**Nothing has to recognise a provider's auth header on the way in.** Detecting +pass-through would require knowing that Anthropic reads `x-api-key` while OpenAI reads +`Authorization: Bearer`, and being wrong in either direction is a leak or a broken call. +Requiring our own header on the data plane removes the question: there is nothing to +detect, because there is no branch. A provider's auth header is named only on the +*injection* side, by the adapter that already knows which secret it holds. + +The passthrough adapter forwards `x-api-key` and every other caller header today. The one +thing standing between current behaviour and this rule is that `Authorization` is stripped +unconditionally, which is a line to change when a caller has a reason to send one. + +**What this does not decide** is whether an operator may forbid it — a project that does not +want its spend quietly split across personal subscriptions needs a governance flag, which is +a policy question rather than a routing one, and nobody has asked for it. + +The passthrough adapter already forwards `x-api-key` and every other caller header; the only +thing standing between today's behaviour and this rule is that `Authorization` is stripped +unconditionally. + +**What a column would still be worth** is the opposite statement: an operator forbidding +pass-through on a target, so a project cannot quietly split its spend across personal +subscriptions. That is a policy flag rather than a mode, it belongs with the other +governance flags, and nobody has asked for it. + +### OD16. Which upstreams a relay-only gateway actually reaches — CLOSED by WP24 + +D34 forbids body conversion outright and keeps routing and authentication, which settles the +principle. What is open is the consequence: **which upstreams remain reachable, through +which front door, once nothing may rewrite a body.** + +This is a per-provider fact and not an argument. For each upstream, three questions: + +1. **Does it accept the bytes a front door would relay?** Azure OpenAI takes the OpenAI + body unchanged, so Chat Completions reaches it today. A Bedrock Anthropic model takes the + Anthropic Messages body, so it needs the `/v1/messages` front door and reaches nothing + before that. The answer is read from the provider's own request schema, not inferred from + which adapter currently handles it. +2. **Can the URL be composed from route fields?** Azure needs `base_url`, the deployment + name and an API version; Bedrock needs the region and the model id. Whether the model id + comes from the path or the body changes what the relay has to touch — and if it must come + out of the body, that provider fails question 1 rather than passing this one. +3. **Can its auth be applied without touching the body?** A header, however named, is + trivial. A signature over the request is allowed but is real work, and SigV4 signs the + body it is given, which is compatible with relaying it and not with rewriting it. + +**The expected shape of the answer**, to be confirmed rather than assumed: Azure moves to a +plain relay with URL composition and a renamed auth header. Bedrock and Vertex become +reachable through the front door matching the body they take, with signing. The `direct` +providers whose wire is not OpenAI's — Anthropic, Gemini, Cohere — are reachable only +through their own front doors, and are not reachable at all until those land. + +**The cost worth stating plainly.** Until the second front door exists, the gateway reaches +OpenAI-shaped upstreams and OpenAI-compatible custom endpoints, and nothing else. That is a +smaller set than today's provider table suggests, and it is the honest consequence of D34 +rather than a gap in it. + +**What this unblocks if it resolves the expected way.** `select_upstream`'s `direct` branch +is the last thing on a stored row that reads `provider_key` (entities.md §2.4). With the +split gone, the column decides nothing and becomes a label — at which point its `NOT NULL` +should go with it. + +--- + +**CLOSED (WP24, phase 0).** All three front doors ship (D38), so every provider below is +checked against whichever door matches its own wire, not only Chat Completions. Sourced +from each provider's own current documentation (dated where the fact is recent), not from +what today's adapter does. + +**The headline result is the opposite of the doc's "expected shape" above: nearly +everything clears, and the reason is that four of the six `_DIRECT_TRANSLATED_PROVIDERS` +already ship an OpenAI-compatible endpoint of their own, and Anthropic's native wire now +has a matching front door.** litellm's translated path was carrying providers that do not +need translation at all — they need a base URL and a bearer header, exactly what the +passthrough adapter already does. The `passthrough`/`translated` split was never a +provider-shape boundary; it was "does litellm's default base URL happen to work", which is +a different question. + +| Provider | Q1: accepts the relayed bytes | Q2: URL from route fields | Q3: auth without touching body | Verdict | +| --- | --- | --- | --- | --- | +| `anthropic` | Yes — its own wire *is* Messages; reachable at `/v1/messages` now that the door exists. | Yes — fixed base URL, no per-row fields needed. | Yes — `x-api-key` header (Anthropic's own name for the same job as Azure's `api-key`), no `anthropic-version` header injected by us (the caller sends it, same as any other vendor-specific header a harness speaking Anthropic's protocol already knows to send). | **Cleared.** Messages door. | +| `gemini` | Yes — Google ships an OpenAI-compatible endpoint (`/v1beta/openai/chat/completions`, confirmed current). | Yes — fixed base URL. | Yes — plain bearer `Authorization`. | **Cleared.** Chat Completions door, via the compat endpoint (not `generateContent`). | +| `cohere` | Yes — Cohere ships a "Compatibility API" (`api.cohere.ai/compatibility/v1`, confirmed current) built for exactly this. | Yes — fixed base URL. | Yes — plain bearer `Authorization`. | **Cleared.** Chat Completions door, via the compat endpoint (not v2 chat). | +| `deepinfra` | Yes — DeepInfra's documented base URL is already OpenAI-compatible (`api.deepinfra.com/v1/openai`). | Yes — fixed base URL. | Yes — plain bearer `Authorization`. | **Cleared.** Was miscategorized as translated; it was always OpenAI-shaped. | +| `perplexityai` | Yes — `api.perplexity.ai/chat/completions` is documented as an OpenAI-SDK-compatible alias of Perplexity's own Sonar endpoint. | Yes — fixed base URL. | Yes — plain bearer `Authorization`. | **Cleared.** Same miscategorization as DeepInfra. | +| `minimax` | Yes — MiniMax documents an OpenAI-compatible Chat Completions route (`api.minimax.io/v1/chat/completions`). | Yes — fixed base URL. | Yes — plain bearer `Authorization`. | **Cleared.** Same miscategorization. | +| `azure` | Yes — Azure OpenAI's deployed-model wire is the OpenAI Chat Completions body, unchanged. | Yes — `base_url` + `/openai/deployments/{model}/chat/completions` + `?api-version=` from `route.api_version`. The deployment name is `route.model`, matching the existing catalogue convention (entities.md §2.4's Azure example never carries a separate deployment field). | Yes — `api-key` header, not `Authorization`, no signature. | **Cleared,** as the doc expected. | +| `bedrock` | Yes, via `bedrock-mantle`: AWS's current-generation Bedrock endpoint (`bedrock-mantle.{region}.api.aws`) speaks OpenAI Chat Completions for most models and Anthropic Messages for Claude models, both unmodified bodies. The older `InvokeModel` wire (`/model/{id}/invoke`) still requires an injected `anthropic_version` field for Claude models, which we do not add — a caller building a Bedrock-flavored Messages body itself (D34's "translation moves to the client") can still reach `InvokeModel`, but the mantle path needs nothing extra from the caller and is the one this package wires. | Yes — `https://bedrock-mantle.{region}.api.aws` + protocol path, region from `route.region`. | Yes — **plain bearer**, not SigV4: mantle accepts a Bedrock API key as `Authorization: Bearer ` (falling back to SigV4 only when no key is supplied, which this design never does). Confirmed by AWS's own docs and by litellm's `bedrock_mantle` provider module, vendored in this repo. | **Cleared, and simpler than the doc's expected shape** — no signing needed for the deployment this package wires. | +| `vertex_ai` (Gemini) | Yes — Vertex ships the same OpenAI-compatible layer as the direct Gemini API, at `.../endpoints/openapi/chat/completions`. | Yes — `https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/endpoints/openapi`, region from `route.region`, project from `route.extras["vertex_project"]`. | Yes — bearer, but the token is minted (a service-account OAuth2 access token), never presented as a static secret. Token minting is real work and is the "signing where the scheme is a signature" carve-out D34 names; this package reuses litellm's own Vertex credential helper (`VertexBase.get_access_token_async`) rather than its request/response transformation, so the body is still never touched. | **Cleared.** Chat Completions door. | +| `vertex_ai` (Claude) | Conditionally — Anthropic's Vertex wire (`:rawPredict`/`:streamRawPredict`) needs an injected `anthropic_version: "vertex-2023-10-16"` field the plain Anthropic Messages API does not use. Same shape as Bedrock's legacy `InvokeModel`: reachable only if the caller builds a Vertex-flavored Messages body itself. | Same as above. | Same as above. | **Not wired by this package.** A caller-side concern per D34; nothing here special-cases it. | +| `sagemaker` | **No, categorically.** `InvokeEndpoint` has no platform-level request schema — AWS documents it as opaque bytes forwarded verbatim to whatever container the customer deployed. There is no "SageMaker's own wire" to check against a front door; the answer is "maybe", per deployment, which is not a fact this design can pin. | Yes in principle — endpoint name in the URL path, region for the host — but moot given Q1. | Yes — SigV4, real work, allowed. | **Not cleared.** Recorded unreachable: `select_upstream` raises, naming that SageMaker has no fixed protocol rather than naming a specific one it needs. | + +**A second finding, not one of the three questions but blocking regardless.** Every +provider's catalogued model ids (`supported_llm_models`) carry litellm's own routing +prefix (`"anthropic/claude-sonnet-5"`, `"groq/moonshotai/kimi-k2-instruct-0905"`, …) — +needed for `litellm.acompletion`'s dispatch, meaningless to the upstream itself. Relaying +one of these ids byte-for-byte in the request body reaches the real upstream with a model +id it does not recognise, for every direct provider, not only the six moved off +`translated` — this was already latent in the existing `passthrough` set (`groq`, +`together_ai`, `openrouter`, `mistral`). D34 forbids fixing this at relay time (touching +the body); the fix is upstream of the relay, in what the catalogue advertises. `catalog.py` +now strips each provider's own litellm prefix from its allowlist, using the SDK's existing +`litellm_provider_prefixes` table in reverse, so the id a caller copies from the allowlist +is the id the real upstream expects. + +**Moves existing providers only, per spec.** `deepinfra`, `perplexityai` and `minimax` were +never translated in fact, only in classification — moving them is correcting a +misclassification, not adding a provider. `sagemaker`'s removal is a correction in the same +direction: it was never really reachable through `translated` either, since litellm's +SageMaker handler assumes an OpenAI/HF-TGI-shaped container that is a deployment choice, +not a platform guarantee — `translated` was silently narrower than its name implied. + +### OD17. Which MCP servers a stateless relay actually reaches — CLOSED by WP15 + +The MCP twin of OD16, and open for the same reason: D8 settled which revision we **build** +to, and nothing settled what happens when an upstream server speaks an **older** one. + +The gateway is stateless end to end. `POST` is the only relaying verb; `GET` and `DELETE` +on both proxy paths are refused rather than proxied, because those are the SSE and +session-teardown legs the 2026-07-28 revision removed. Routing reads `MCP-Method` and +`MCP-Name` from headers and never parses the body — which is what lets the tool filter +refuse a call before the upstream is dialled, and is not something a session-based revision +would allow. + +**Verdict: the reachable set is not a session-revision problem.** Every server WP15 is +tested against, and every real-world candidate probed against its own documentation or +live, answers a plain stateless POST. Nothing in the probed set needed detect-and-refuse or +session carrying, so D8 stands unchanged and this closes without reopening it. + +Per-server findings, against the three questions (plain stateless POST; header-based +routing vs. body-only method; SSE needed for ordinary calls): + +1. **`mock-mcp-gateway` (WP5, wave 1's tested target).** Source: its own implementation + (`core/gateways/mcps/providers/mock/app.py`). Stateless JSON mode by construction: one + JSON-RPC request in, one `application/json` response out (`202` for a notification), no + `Mcp-Session-Id`, no initialize handshake before `tools/list`. `GET`/`DELETE` answer + `405` at the mock itself, matching the gateway's own refusal. **Reachable.** + +2. **DeepWiki (`mcp.deepwiki.com/mcp`), unauthenticated, live-probed** (a plain `POST + tools/list`, no session header, no prior `initialize` call): answered `200` with the + full tool list on the first request. The response rides a single `text/event-stream` + event on the POST's own connection rather than a bare JSON body — allowed by the current + spec for a stateless responder, and relayed byte-for-byte by `HttpMCPAdapter`, which + never inspects content-type. No session id was minted or required. **Reachable**, and + representative of "the handful we expect to route first": a real, unauthenticated, + current-revision server with no operator-side setup. + +3. **Context7 (`mcp.context7.com/mcp`).** Documented as OAuth-gated on first connect. Out of + this package's reachable set on the auth axis (D23: wave 1 is unauthenticated servers and + the mocks) before its session behaviour is even relevant — wave 3's problem (WP16–WP20), + not this one's. Not probed further. + +**Why this doesn't reopen D8.** The failure mode OD17 worried about — a server half-working +by accident, POST succeeding while the GET leg it needs is refused — was not observed +because no probed server needed the GET leg for an ordinary call. Nothing here found a +server on the prior (session-carrying) revision at all, so there was no "large stale group" +to trade a cheap refusal against carrying state for. If a genuinely stale server turns up +later, it fails cleanly today (`GET`/`DELETE` refused, `POST` alone is not enough for it to +complete a handshake) rather than half-working — the confusing case OD17 flagged did not +materialize in this set, so re-deciding D8 stays out of scope here as the spec required. + +### OD18. Does a harness's SDK preserve the gateway's refusal body in its error text — CLOSED by WP25 + +OD14 verified that each harness sends OUR credentials header outbound. It said nothing about +the return trip: whether the JSON body the gateway attaches to a pre-dial refusal +(`{"error":{"message","type","code",...}}`, `apis/fastapi/gateways/llms/proxy.py` +`_map_domain_exception`) survives into the text the harness reports, which is the only signal +`gateway-error.ts`'s `parseGatewayErrorDetail` has to recover the cause from. Verified against +the same pinned releases OD14 used (`services/runner/package.json`), by reading each harness's +own error-formatting source rather than by a live call. + +1. **Pi (`@earendil-works/pi-ai@0.80.6`, pinned via `pi-coding-agent@0.80.6`). Preserves it.** + Two independent paths, both confirmed from source: + - The OpenAI-shaped API clients (`api/openai-completions.js`, `api/openai-responses.js`) + route every provider error through a shared `normalizeProviderError`/`formatProviderError` + pair (`utils/error-body.js`), written explicitly for "endpoints behind a proxy / gateway" + (the file's own header comment) — it reads the SDK's parsed body field and + `JSON.stringify`s it into the message whenever the SDK's own message does not already + carry it. + - The Anthropic-shaped client (`api/anthropic-messages.js`) uses `@anthropic-ai/sdk@0.111.0` + directly, whose `APIError.makeMessage` (`core/error.js`) falls back to + `JSON.stringify(errorResponse)` whenever the parsed body has no top-level `message` key — + true for our gateway's `{"error":{...}}` shape, which nests `message` one level down. The + full body reaches `error.message` verbatim. + +2. **Claude Code (`@agentclientprotocol/claude-agent-acp@0.58.1`, CLI driven by + `@anthropic-ai/claude-agent-sdk@0.3.205`). Not independently verifiable from source, and + recorded as such rather than assumed.** The ACP bridge itself does not reformat: on a failed + turn it forwards the CLI's own `result` string unmodified into `RequestError.internalError` + (`dist/acp-agent.js`, the `subtype: "success"` / `is_error` branch). What that string + contains is decided inside the Claude Code CLI binary, which `@anthropic-ai/claude-agent-sdk` + downloads and runs as a compiled, closed-source executable (`extractFromBunfs.js`) — there is + no bundled source to read, matching the limit OD14 hit on the same package for the + subscription-login question. Since the CLI is Anthropic's own client against Anthropic's own + Messages API, it is a reasonable inference that it shares `@anthropic-ai/sdk`'s + body-in-message convention verified above for Pi — but that is an inference, not a reading, + and is recorded as unverified per this package's own rule (a harness is a fact, not an + assumption). `tests/unit/gateway-error-harness-formats.test.ts` covers the format Pi and the + Anthropic SDK are confirmed to produce, exercised as a stand-in for Claude Code's most likely + shape, and is flagged in-file as unconfirmed for Claude Code specifically. + +3. **Codex (`@openai/codex@0.145.0`, ACP bridge `@agentclientprotocol/codex-acp@1.1.7`). Does + NOT preserve the body — confirmed, not inferred.** `codex-rs`'s HTTP error path + (`codex-rs/protocol/src/error.rs`, `UnexpectedResponseError::extract_error_message`, read at + tag `rust-v0.145.0`, matching the pinned npm version) actively parses the response body as + JSON and keeps only `error.message`, discarding `code`, `type`, and every other field before + formatting `"unexpected status {status}: {message}"`. The ACP bridge forwards that already- + stripped string as-is (`dist/index.js`, `createErrorEvent`, `params.error.message`). No brace + survives for `parseGatewayErrorDetail`'s body scan to find. **This is not the end of the + story** — see the marker fallback below, which this finding motivated. + +**First consequence, then corrected: the marker fallback.** The first cut of this package +recorded Codex's gap as a known degradation and stopped there. That does not meet WP25's own +"done when" — WP19's step-up interaction is built on this channel, and a refusal that reaches +Codex with no `code` is a run that cannot ask the user to fix it. The fix is on our side, and +Codex's own finding points at it: `error.message` survives on every harness examined, Codex +included — codex-rs keeps exactly that one field. So the gateway now renders every TYPED +refusal's `message` with a single machine-readable marker appended +(`⟦agenta_code:⟧`, `_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={ + + } + tableProps={{ + size: "small", + bordered: true, + tableLayout: "fixed", + locale: { + emptyText: ( + + + No MCP servers yet + + + Register a server by URL to give your agents new tools. + +
+ } + > + + + ), + }, + 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() +})