feat: add /api/v2/aibridge/serve endpoint (#26506)

Adds a new enterprise-only `GET /api/v2/ai-gateway/serve` endpoint that standalone AI Gateway replicas use to connect to `coderd` over a DRPC-over-WebSocket transport, mirroring the existing in-memory path used by the embedded AI Bridge daemon.

- The endpoint upgrades the HTTP connection to a WebSocket, multiplexes it with yamux, and finally serves the three DRPC services (Recorder, MCPConfigurator, Authorizer).
- The `X-AI-Governance-Gateway-Key` header is used for authentication.
    - The key is looked up by its hashed secret
    - Missing or revoked keys return `401`.
- API version negotiation is enforced via a new `aibridged/proto` version (`v1.0`).
    - Incompatible versions return `400`.
- `FeatureAIBridge` entitlement is required.
- Key liveness (`last_used_at`) is recorded immediately on connection and refreshed every 60 seconds while the session remains open.
  - When key liveness detects the key was deleted (no rows where updated) session is closed.

#### Small refactors

* The three DRPC service registrations are extracted into `aibridgedserver.Register`, shared by both the in-memory and WebSocket paths.

* The literal `256 * 1024` used as the yamux-aligned WebSocket read limit is replaced with the named constant `drpcsdk.YamuxDefaultStreamWindowSize` in all call sites.
  * as noted in review comment https://github.com/coder/coder/pull/26506#discussion_r3461905223 order of `SetReadLimit` and `WebsocketNetConn` calls was fixed.
This commit is contained in:
Paweł Banaszewski
2026-06-26 18:27:37 +02:00
committed by GitHub
parent ad355aeaa9
commit 6189d6e386
17 changed files with 616 additions and 25 deletions
+2 -12
View File
@@ -6,7 +6,6 @@ import (
"io"
"net/http"
"golang.org/x/xerrors"
"storj.io/drpc/drpcmux"
"storj.io/drpc/drpcserver"
@@ -71,17 +70,8 @@ func (api *API) CreateInMemoryAIBridgeServer(dialCtx context.Context) (client ai
if err != nil {
return nil, err
}
err = aibridgedproto.DRPCRegisterRecorder(mux, srv)
if err != nil {
return nil, xerrors.Errorf("register recorder service: %w", err)
}
err = aibridgedproto.DRPCRegisterMCPConfigurator(mux, srv)
if err != nil {
return nil, xerrors.Errorf("register MCP configurator service: %w", err)
}
err = aibridgedproto.DRPCRegisterAuthorizer(mux, srv)
if err != nil {
return nil, xerrors.Errorf("register key validator service: %w", err)
if err := aibridgedserver.Register(mux, srv); err != nil {
return nil, err
}
server := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux},
drpcserver.Options{
+19
View File
@@ -0,0 +1,19 @@
package proto
import "github.com/coder/coder/v2/apiversion"
// Version history:
//
// API v1.0:
// - Initial version. Serves the Recorder, MCPConfigurator, and Authorizer
// services to embedded and standalone AI Gateway daemons.
const (
CurrentMajor = 1
CurrentMinor = 0
)
// CurrentVersion is the current aibridged API version.
// Breaking changes to the aibridged API **MUST** increment CurrentMajor above.
// Non-breaking changes to the aibridged API **MUST** increment CurrentMinor
// above.
var CurrentVersion = apiversion.New(CurrentMajor, CurrentMinor)
+7 -7
View File
@@ -594,13 +594,13 @@ externalAuthLoop:
// IsAuthorized validates a given Coder API key and returns the user ID to which it belongs (if valid).
//
// SECURITY: when in.KeyId is set (the "delegated" path), this method trusts the
// caller's claim of identity and skips the key-secret check. This is safe only
// because the DRPCServer is reachable solely via the in-process
// [aibridged.MemTransportPipe]; the handler itself cannot tell whether it was
// invoked over the in-memory pipe or a network socket. If this RPC is ever
// exposed over a network boundary, any caller who knows a valid 10-char key ID
// (which is not secret) could authenticate as the key's owner without the
// secret. Do not bind this DRPCServer to a network listener.
// caller's claim of identity and skips the key-secret check. This DRPCServer is
// reachable both in-process via [aibridged.MemTransportPipe] and over the network
// via the /api/v2/ai-gateway/serve endpoint. That endpoint admits only holders of
// AI Gateway key, which are fully trusted. Standalone AI Gateway authenticates its
// own users and acts on their behalf, much like a provisioner daemon. A Gateway key
// holder can therefore act as any user without that user's secret. Per-user
// authorization on this surface is a known gap.
//
// NOTE: this should really be using the code from [httpmw.ExtractAPIKey]. That function not only validates the key
// but handles many other cases like updating last used, expiry, etc. This code does not currently use it for
+25
View File
@@ -0,0 +1,25 @@
package aibridgedserver
import (
"golang.org/x/xerrors"
"storj.io/drpc/drpcmux"
"github.com/coder/coder/v2/coderd/aibridged/proto"
)
// Register registers the Recorder, MCPConfigurator, and Authorizer DRPC
// services backed by srv onto mux. It is shared by the embedded in-memory
// server and the standalone /api/v2/ai-gateway/serve WebSocket handler so both
// expose an identical service set.
func Register(mux *drpcmux.Mux, srv *Server) error {
if err := proto.DRPCRegisterRecorder(mux, srv); err != nil {
return xerrors.Errorf("register recorder service: %w", err)
}
if err := proto.DRPCRegisterMCPConfigurator(mux, srv); err != nil {
return xerrors.Errorf("register MCP configurator service: %w", err)
}
if err := proto.DRPCRegisterAuthorizer(mux, srv); err != nil {
return xerrors.Errorf("register authorizer service: %w", err)
}
return nil
}
+24
View File
@@ -1532,6 +1532,25 @@ const docTemplate = `{
]
}
},
"/api/v2/ai-gateway/serve": {
"get": {
"tags": [
"Enterprise"
],
"summary": "AI Gateway serve",
"operationId": "ai-gateway-serve",
"responses": {
"101": {
"description": "Switching Protocols"
}
},
"security": [
{
"AIGatewayKey": []
}
]
}
},
"/api/v2/ai-gateway/sessions": {
"get": {
"description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.",
@@ -28912,6 +28931,11 @@ const docTemplate = `{
}
},
"securityDefinitions": {
"AIGatewayKey": {
"type": "apiKey",
"name": "X-AI-Governance-Gateway-Key",
"in": "header"
},
"Authorization": {
"type": "apiKey",
"name": "Authorizaiton",
+22
View File
@@ -1355,6 +1355,23 @@
]
}
},
"/api/v2/ai-gateway/serve": {
"get": {
"tags": ["Enterprise"],
"summary": "AI Gateway serve",
"operationId": "ai-gateway-serve",
"responses": {
"101": {
"description": "Switching Protocols"
}
},
"security": [
{
"AIGatewayKey": []
}
]
}
},
"/api/v2/ai-gateway/sessions": {
"get": {
"description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.",
@@ -26681,6 +26698,11 @@
}
},
"securityDefinitions": {
"AIGatewayKey": {
"type": "apiKey",
"name": "X-AI-Governance-Gateway-Key",
"in": "header"
},
"Authorization": {
"type": "apiKey",
"name": "Authorizaiton",
+4
View File
@@ -338,6 +338,10 @@ type Options struct {
// @securitydefinitions.apiKey CoderSessionToken
// @in header
// @name Coder-Session-Token
// @securitydefinitions.apiKey AIGatewayKey
// @in header
// @name X-AI-Governance-Gateway-Key
// New constructs a Coder API handler.
func New(options *Options) *API {
if options == nil {
+5
View File
@@ -370,6 +370,11 @@ func assertSecurityDefined(t *testing.T, comment SwaggerComment) {
comment.router == "/api/v2/init-script/{os}/{arch}" {
return // endpoints do not require authorization
}
if comment.router == "/api/v2/ai-gateway/serve" {
assert.Equal(t, "AIGatewayKey", comment.security, "@Security must be AIGatewayKey")
return
}
assert.Containsf(t, authorizedSecurityTags, comment.security, "@Security must be either of these options: %v", authorizedSecurityTags)
}