[feat] Add Gateways (ongoing and far from properly tested) - #6049
[feat] Add Gateways (ongoing and far from properly tested)#6049junaway wants to merge 199 commits into
Conversation
…s by upstream The development tunnel published the object store, and nothing published the API. Two things now need the opposite: a platform posting a webhook, and an authorization server fetching a document we serve. It forwards to traefik instead, 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 route needs a tunnel of its own. NGROK_DOMAIN pins a reserved domain, which is what keeps an address handed to a provider valid across restarts. A browser redirect never needed this. The user is already looking at the deployment, so the address that got them there is one their browser reaches. discoverTunnelEndpoint returned the first https tunnel without checking what it forwarded to, which was correct only while exactly one tunnel existed and it happened to be the store's. It now matches on the upstream host and port and returns nothing when no tunnel serves the store, because handing back another tunnel's URL would mount an HTTP API as an object store and fail far from its cause. The caller already refuses the mount and tells both the operator and the model why. One configuration loses a convenience, stated rather than discovered: Daytona sandboxes with the bundled in-network store no longer get a durable working directory. Point the store at a public endpoint -- production runs no bundled store at all -- or run a second tunnel for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches the project-workspace convention the other design workspaces use. No work packages: the tunnel target, the selector fix and the capability that moves only make sense together, and landing the repoint without the selector would leave it returning the wrong URL. The tasks list carries the deploy-and-verify checks, each one chosen because it can fail quietly -- including that a refused sandbox mount must be audible, and that a Daytona run against a public store must keep working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every worktree reads the design from its own checkout rather than a second one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…one workstreams Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…re's Corrects the previous commit, which repointed the store's tunnel at the ingress instead of adding one. That silently cost Daytona sandboxes with the bundled store their durable working folder, and it was not a trade anyone asked for. The store tunnel is restored byte-for-byte from main: same target, same comments, same depends_on. The compose diff is purely additive -- zero removed lines in either edition. The new ngrok-api service forwards to traefik, so anything outside that must reach us arrives on its normal path: /api/ is already routed in dev, in the self-host files, and in production, so one endpoint serves channels, the model and MCP gateways, and whatever follows. NGROK_API_DOMAIN pins a reserved domain, which is what keeps an address handed to a provider valid across restarts. Two tunnels means two agent sessions; if a plan allows only one, a single agent with two named endpoints works too, and the selector fix is what makes that safe. That selector took the first https tunnel regardless of what it forwarded to -- correct only while one tunnel existed, which is exactly what this change ends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…uard R1 exceptions.py -> the seed; R2 the resolver port gains available_provider_keys rather than the LLM service gaining a vault dependency; R3 GET /v1/models is backed by list_models; R4 the policy record method ships as a safe no-op. R7 settled as D28: reuse core/webhooks/utils.py at both ends -- the no-DNS gate at registration, the resolving one at relay with the literal IP pinned. Also records that AGENTA_INSECURE_EGRESS_ALLOWED defaults true and is set nowhere, so the guard is inert until a deployment turns it off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ngrok becomes ngrok-fs, beside the new ngrok-api. Two tunnels with one generic name and one specific one reads as if the first were the default and the second an afterthought; both are peers. The store tunnel keeps its target, its token gate and its comments. Only the name moves -- and with it the one thing that addresses it: nothing sets AGENTA_MOUNTS_TUNNEL_API, so the runner reaches the agent through a compiled-in default, now http://ngrok-fs:4040. The variable stays an override, and the service name is load-bearing because of it. An operator with a stack already up gets an orphaned ngrok container; the deploy list says to pass --remove-orphans once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Declarations only for the gateways domain (entities.md), so nine parallel
work packages can fork from this commit and fill in the bodies they own.
Every function/method body is `raise NotImplementedError`, docstrings are
transcribed from the design; DTOs are complete and constructible, and
domain exceptions carry real __init__ bodies (they are structure, not
behaviour).
Adds core/gateways/{policy,llms,mcps}/{dtos,types,interfaces}.py (dtos.py
and types.py for the domain root and policy/ were already in place),
apis/fastapi/gateways/exceptions.py (handle_gateway_exceptions, written
once per R1), an empty gateways scaffold in api/entrypoints/routers.py
(imports + a commented four-line mount block, nothing wired), and one
unit test importing every new module and instantiating every DTO.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
handle_gateway_exceptions() is seed-owned (R1) and no work package fills it in, so a not-implemented body would leave it unowned. It maps exceptions the seed itself defines and depends on nothing, so it ships complete. The three credential/step-up arms are not in the design's table; mapped to 409 on section 5's own words and recorded as R11 for confirmation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he seed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ngrok-fs becomes ngrok-mounts and ngrok-api becomes ngrok-ingress. The second tunnel forwards to traefik, so it publishes the whole deployment and not just the API; its old name claimed otherwise. NGROK_API_DOMAIN follows as NGROK_INGRESS_DOMAIN. Drop AGENTA_MOUNTS_TUNNEL_API. It overrode the address of the tunnel daemon the runner queries for the store's public URL, was never set in any compose file, env example or deployment, and the compose service name it defaulted to already resolves. Tests inject through the deps seam, which stays. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l domain NGROK_INGRESS_DOMAIN becomes NGROK_DOMAIN_INGRESS. Vendor-prefixed vars put the attribute before the instance -- REDIS_URI_DURABLE and REDIS_URI_VOLATILE beside a shared REDIS_URI, POSTGRES_URI_CORE and its siblings. Our own AGENTA_* namespace narrows the other way, which is what the first name followed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tunnel vars sat unbannered at the tail of the Daytona section. Now that there are two of them driving two services, they get a header like every other vendor block. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tlement gate R9: litellm reached the API only transitively through the SDK; WP7's translated adapter needs it, so it is declared. Lock delta is two lines, no version churn. R5 closed as D29: every user has both gateways, so an entitlement check asks a question with one answer. What entitlements will express here are limits, which cannot be enforced before anything is measured, so they ship with metering and billing. No placeholder key -- a call that always permits reads as enforcement. EntitlementDeniedError stays declared and mapped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One `ngrok http` can be listed twice by the agent API, once as http and once as https over the same upstream. The upstream match took whichever came first, so geesefs could reach the store unencrypted over the internet. The unmatched path already preferred https; this makes both paths agree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six new Permission members (VIEW/EDIT/USE x LLM/MCP endpoints) plus their wiring into VIEWER/ANNOTATOR/EDITOR's default_permissions() lists, following the RUN_TOOLS/USE_MOUNTS precedent. DEVELOPER/ADMIN/OWNER pick them up through the existing superset chain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
authorize() is the sole permission check via check_action_access, fail-closed by construction (no try/except around the call, so an unexpected exception propagates rather than defaulting to allowed=True). No entitlement arm (D29): every user has both gateways today, and entitlements will express limits once metering ships, not before. record() ships as the wave-1 stub ruled at kickoff (R4): a safe no-op that never raises, since every wave-1 relay calls it unconditionally on both the allow and deny branch. Its real body is WP4's policy/audit.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers authorize()'s allow/deny/propagate-on-exception paths (mocking check_action_access at the module boundary), record()'s no-op/no-publish contract, and the six permissions' role wiring (VIEWER/ANNOTATOR/EDITOR plus superset propagation to DEVELOPER/ADMIN/OWNER). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Marks WP3's checklist complete and records the merge SHAs. Adds the GatewayPolicyService(resolver=credential_resolver) construction line for api/entrypoints/routers.py as a diff inside this file, per the package's "routers.py is owned by nobody" rule — WP3 does not edit that file directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements CredentialResolverInterface (entities.md §7.2) over VaultService and McpGrantsDAOInterface: resolve() dispatches BoundSecretRef, ProviderKeyRef, and GrantRef to one private method each, writing the full three-way CredentialMode table explicitly per arm rather than collapsing it (USER_REQUIRED never falls back to the project's credential). Adds available_provider_keys() (R2) over the same provider_key/custom_provider scan, returning provider names only and never raising on an empty project. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers every case in specs-wp2.md's Tests section: all three ref arms, the provider_key-over-custom_provider priority order, and the full CredentialMode table on GrantRef (PROJECT_ONLY not falling through to a user grant, USER_REQUIRED never falling back to the project's, USER_OPTIONAL naming the narrower missing owner). Also covers the invalid-grant short-circuit (no vault read before the is_valid check) and the dangling secret_id defensive case. Fakes VaultService and McpGrantsDAOInterface as plain in-memory dicts, no Postgres/Redis/encryption key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Marks every WP2 checklist item done: the CredentialResolver implementation, the full mode table per ref arm, available_provider_keys, and the unit test suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Abstract SQLAlchemy mixins for the three gateway tables, per entities.md §2. The dbas.py file exists for these domains (unlike the single-table gateway/connections precedent) because llms/ and mcps/ each own more than one table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Concrete tables llm_gateway_endpoints, mcp_gateway_endpoints and mcp_gateway_grants with every constraint and index from entities.md §3: composite (project_id, id) PKs, per-table secret_id FK ondelete behavior (SET NULL on endpoints, CASCADE on grants), the project-scoped slug unique constraints, and the two partial-unique grant indexes for a nullable owner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The entry described the credits work's proposal A -- a signed token carrying its own claims -- as its recommendation. Its report rejects that shape and takes an opaque token with a stored digest, because every call already needs a Postgres transaction for the hold, so statelessness buys nothing and costs revocation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FakeLlmAdapter implements LlmUpstreamInterface with no socket, no process,
registered under the "fake" adapter key (D23). Controllable via context.model:
fake/echo (default, echoes the last message, 1 chunk or 2-3 SSE chunks +
[DONE] when streaming), fake/error (raises LlmUpstreamError), fake/slow-{n}
(sleeps n seconds first). GatewayUsage is populated once body is exhausted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FakeMcpAdapter implements McpUpstreamInterface as its own upstream: it parses the caller's JSON-RPC body and answers in-process. Three tools (echo, fail, slow) served via tools/list and tools/call; fail returns isError: true as a JSON-RPC result, never an exception (D16 pass-through). notifications/* get 202 with an empty body; any other method raises McpUpstreamError(501) — no fourth method to fake. Scope-challenge tooling stays unbuilt per D23's "later". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
map_{llm_endpoint,mcp_endpoint,mcp_grant}_create_to_dbe / _dbe_to_dto /
_edit_to_dbe (grants have no edit mapper: update_grant patches fields
directly, there is no McpGrantEdit). data/flags dump via
model_dump(mode="json", exclude_none=True) going in and reconstruct the
typed model going out; *_dbe_to_dto stamps namespace=CUSTOM unconditionally
per entities.md §2.3/§7 — every row this package persists is custom by
construction.
Unit tests round-trip one representative Create DTO per entity through the
DBE and back with no database, plus serialization checks for each seed
payload DTO touched by the mappings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A parametrized fixture both FakeLlmAdapter/FakeMcpAdapter and (once they land)
PassthroughLlmAdapter/TranslatedLlmAdapter/HttpMcpAdapter/ComposioMcpAdapter
must pass: relay_chat_completion always returns LlmRelayResult and relay
always returns McpRelayResult across {initialize, tools/list, tools/call},
never a raw dict. Not-yet-implemented adapters are parametrized in as a skip
so the contract starts enforcing itself the moment WP6/WP7/WP8/WP9 land, with
no edits needed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Documents the two-step POST /connect route design, the unauthenticated callback, scope selection, and the new custom-MCP-registration surface before any code lands.
POST /endpoints/{id}/connect (discover-then-begin, two calls disambiguated
by whether scopes is present) and GET /connect/callback (unauthenticated,
driven by the signed state) land on MCPGatewayRouter, backed by WP17's
MCPOAuthConnectService. Maps the OAuth client's typed exceptions to HTTP,
and updates the WP17 scaffold tests for the new scopes:None semantics and
the routes now being registered.
New settings page for registering a custom MCP server by URL and connecting an OAuth one: MCPEndpoints (list + register/edit), MCPEndpointDrawer (create/edit form), MCPConnectDialog (scope checklist -> popup OAuth, mirroring gatewayTool/ConnectDrawer's postMessage-or-poll completion). Raw axios against the two new routes (api.ts) — no generated Fern client for this domain yet, noted in specs-wp18.md. Mounted as a new "mcpEndpoints" settings tab.
…t affordance Raises MCPScopeInsufficientError from a live RFC 6750 insufficient_scope challenge on a custom OAuth endpoint's upstream (previously declared but never triggered), and attaches a connect affordance pointing at WP18's own connect route. Repoints WP26's request_connection MCP branch at WP18's real MCPConnectDialog for a registered custom endpoint, settling on its real postMessage signal instead of the Composio-catalog optimistic-close stopgap; the builtin/Composio fallback is unchanged since it still has no per-call completion signal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # docs/design/gateways-research/v1/open-designs.md
# Conflicts: # docs/design/gateways-research/v1/open-designs.md
…nmigrated paths move to out-of-scope CU14 settles the split default as permissive in all four copies: the strict answer breaks zero-config self-hosting, and the SDK is the copy most likely to run on a developer's own machine. Posture comes from CU13 setting the flag false where a deployment is shared, not from the default. The env-resolution tests now assert the discriminating direction, since asserting true no longer distinguishes the variable from the default. OD20, CU3 and CU4 were all the same thing: paths that predate this work, still resolve a secret or call an upstream themselves, and change when they migrate. They are not open questions or pending cleanups, so they move to out-of-scope.md as one entry -- listed once so finding one again reads as not-yet-migrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ssing well-known paths (OD21) MCPOAuthClient.discover() now probes the MCP server unauthenticated first and, on a 401 carrying resource_metadata in WWW-Authenticate (RFC 9728), fetches protected-resource metadata from that URL. Falls back to the existing well-known derivation when there is no 401, no header, or no resource_metadata param, matching OAuthClientProvider's own ordering. Closes OD21. Test suite adds a mock authorization server whose metadata lives only at an unguessable path, discoverable solely via the header — confirmed to fail against the prior well-known-only implementation and pass after this change.
# Conflicts: # docs/design/gateways-research/v1/open-designs.md
…le also serves Messages Vertex's two doors share a host and prefix, so one base_url serves both; Bedrock's are two separate services. But bedrock-mantle serves the Messages API as well as the OpenAI-compatible ones, so routing the Messages door there would collapse Bedrock to one host too -- and may remove the need for D40's field rewrite on this vendor. Whether mantle's Messages takes the model in body or URL is unchecked. PrivateLink and Private Service Connect both replace the host and nothing else, so forbidding base_url would make the gateway unusable from a VPC-only deployment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…drock Messages moves to bedrock-mantle
Bedrock's Messages door now composes bedrock-mantle's /anthropic/v1/messages instead of
InvokeModel on bedrock-runtime: model stays in the body, anthropic-version rides the header
a native Anthropic client already sends, and the relay already forwarded that header and
already authenticated with a bearer token. D40's static-field rewrite table shrinks to
Vertex alone -- Bedrock's entry was an artefact of routing to InvokeModel, not a vendor fact.
base_url is redefined as a pure host override shared by every door a deployment kind serves
(plus, on Vertex, the shared /v1/projects/{project}/locations/{region} prefix), fixing a
latent bug where an explicit Vertex base_url skipped the /endpoints/openapi tail on the
OpenAI-compatible door. Records, without resolving, the conflict between AWS's own two pages
on what bedrock-mantle's Messages surface supports.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An explicit base_url skipped /endpoints/openapi on two of Vertex's three doors. Invisible because nothing registers one -- the exercised fallback composed it correctly. Notes the general shape: an optional field with no caller is not covered by a green suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
WIP -- @mmabrouk |
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository YAML (base), Organization UI (inherited) Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| 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}) |
| content={ | ||
| "error": { | ||
| "message": exc.detail or str(exc), | ||
| "type": "server_error", | ||
| "code": "mock_upstream_error", | ||
| } | ||
| }, |
| except MCPUpstreamError as exc: | ||
| return Response( | ||
| status_code=exc.status_code or 502, | ||
| content=(exc.detail or str(exc)).encode(), |
WIP. Ongoing and far from properly tested. Nothing here has run against a deployment. Unit suites pass; no integration or acceptance run has happened, and no call has ever been made to a real provider or a real MCP server. Opening it as a draft so the shape is reviewable, not because it is ready.
What this adds
Two gateways, one design: every model call and every MCP call leaves the platform through us, and every provider secret lives behind that boundary instead of travelling to the caller.
Before, a caller resolved a provider secret itself and called the provider. The secret reached the sandbox, and "was every call checked" had no single answer. Now the caller addresses a route by name, presents credentials we minted, and the gateway resolves the secret, applies policy, and relays the request.
The route names what to reach, never how to reach it:
Namespaces split by whose secret pays:
builtin(ours),standard(the tenant's key for a known provider),custom(an endpoint the tenant brought).Three properties are worth stating because they constrain everything else.
The relay never converts a body. A request arrives in a provider's own wire format and leaves in the same bytes. One exception exists, and it is a static, literal table that cannot read the request: Vertex's
rawPredictrequiresanthropic_versionin the body and forbidsmodelthere, so those two fields are added and removed by name. Bedrock needed the same treatment until this branch pointed its Messages door atbedrock-mantle, which takes the native Anthropic body unchanged.A target must be registered before an agent can use it. Declaring a URL and a secret in agent code no longer works, because a sandbox can no longer hold a secret. Registration is where a secret goes now, so the CRUD registries on both planes are load-bearing rather than administrative.
A refusal has to survive the trip back. The gateway raises typed errors for a missing credential, a rejected one, an unregistered target, a disallowed model and a deactivated endpoint. Getting those to the agent turned out to be most of the work, for reasons below.
Changes
The LLM plane relays Chat Completions, Responses and Messages across the providers that clear a relay-only design, with per-provider routing that composes a URL from the registered row and never from the body. The MCP plane relays Streamable HTTP against the stateless protocol revision, with an OAuth client for
customservers: consent from the dashboard with scope selection, tokens stored as secrets, and a step-up interaction when a server answers with an insufficient-scope challenge.On the agent side, the runner and SDK carry one minted gateway token instead of per-server secret arrays, and a refusal now arrives as a cause the agent can act on. An agent that cannot reach a model or an MCP server can ask the user to connect it, reusing the same pause-and-resume path that already existed for external integrations.
base_urlon a registered row means the host, and each protocol door appends its own tail. That is what lets a VPC-only deployment point at a PrivateLink or Private Service Connect address, which replaces the host and nothing else.Three findings that changed the design
Codex silently drops our error envelope. Its protocol crate parses the error body, keeps
error.message, and discardscodeandtype. Recovering the cause by parsing the harness's error text therefore worked on Pi and never on Codex. Every refusal now carries its code insidemessage, which every harness preserves, and the runner reads that when the body is gone. Claude Code's CLI is a compiled binary and stays unverified, which matters less now that the code survives either way.On the MCP plane that marker is the only channel, for every harness. MCP speaks JSON-RPC, where the cause sits under a numeric
error.code, and the recovery path tested for a string one. It never matched, even with the body fully intact. Half the gateway could not deliver a cause to anyone.The runner's SSRF guard had holes. Its address tables were transcribed by hand from Python's
ipaddressand had drifted:192.0.0.0/29where the real range is/24, and almost all reserved IPv6 missing. The TypeScript now loads ranges generated from Python's own tables, and a test regenerates and diffs them. Reverting one CIDR was confirmed to turn both languages' suites red.Tests
api3,314 passed.sdks/python2,255 passed.services/oss160 passed.services/runner2,332 passed with 19 pre-existing failures that also fail onmain(workspace-import,commit-authorization,sandbox-agent-acp-interactions).web/oss445 passed across the touched areas.Notes for whoever deploys this first
mcpas a runtime dependency, pullingpydantic-settingsandsse-starlette. An environment built before this branch needs a dependency sync or it fails at import.AGENTA_INSECURE_EGRESS_ALLOWEDnow resolves permissive when unset in all four places that read it, instead of two of them defaulting the other way. Shared deployments set it tofalseexplicitly, and the shipped.gh, Kubernetes and Railway configurations now do.InvokeModelorrawPredict, and an acceptance test written against the mock was deleted rather than kept, because it would have passed against the mock and failed against the vendor.bedrock-mantlesupports structured outputs on Messages. The disagreement is recorded rather than resolved, andbedrock-runtimeremains the endpoint for structured outputs, cross-region inference, guardrails and prompt routing.What is deliberately not here
Local-secret use is uncapped. The counter that gated it measured permission checks rather than usage, so it was removed, and nothing replaces it until that path transits the gateway. That and the other paths still reaching an upstream without the gateway are listed in
docs/design/gateways-research/v1/out-of-scope.md. They predate this work and change when they migrate.Retries, model fallbacks, aliasing, and user-level secrets are out of scope entirely rather than deferred.
The design lives in
docs/design/gateways-research/v1/:decisions.mdfor what is settled and why,open-designs.mdfor what is not,out-of-scope.mdfor what will not be built here.