Files
livekit/pkg/service/agentendpoint_test.go
T
Théo Monnom 8c7011cb7a agent: HTTP endpoints data plane
Serve worker-declared FastAPI routes at /agents/{deployment}/{path} without
any worker-side listener: workers dial a fixed pool of wires speaking
AgentHttp.Frame, the server opens multiplexed streams carrying one opaque
HTTP/1.1 exchange each (two-level credit flow control, prioritized write
scheduler, attach epoch fencing). The front does starlette-exact manifest
matching with per-endpoint public access, typed 401/404/405, a retry table,
and SSE/WebSocket passthrough; a pluggable fallback hook lets multi-node
deployments resolve misses elsewhere. Includes a conformance client, a
manual sidecar, and the acceptance suite.

The auth middleware now reads access_token from the query string only:
FormValue consumed the bodies of proxied url-encoded POSTs.
2026-08-19 18:25:35 -07:00

503 lines
15 KiB
Go

// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package service_test
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/require"
"github.com/livekit/livekit-server/pkg/agent"
"github.com/livekit/livekit-server/pkg/agent/endpoint"
"github.com/livekit/livekit-server/pkg/agent/endpoint/client"
"github.com/livekit/livekit-server/pkg/config"
"github.com/livekit/livekit-server/pkg/routing"
"github.com/livekit/livekit-server/pkg/service"
"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"
"github.com/livekit/psrpc"
)
const (
testKey = "test"
testSecret = "verysecretsecret"
)
type endpointStack struct {
t *testing.T
ts *httptest.Server
svc *service.AgentService
}
func newEndpointStack(t *testing.T, endpointsCfg agent.EndpointsConfig) *endpointStack {
localNode, err := routing.NewLocalNode(nil)
require.NoError(t, err)
keyProvider := auth.NewSimpleKeyProvider(testKey, testSecret)
svc, err := service.NewAgentService(
&config.Config{
Region: "test",
Keys: map[string]string{testKey: testSecret},
Agents: agent.Config{TargetLoad: agent.DefaultTargetLoad, Endpoints: endpointsCfg},
},
localNode,
psrpc.NewLocalMessageBus(),
keyProvider,
)
require.NoError(t, err)
mux := http.NewServeMux()
mux.Handle("/agent", svc)
mux.Handle(endpoint.PathPrefix, svc.EndpointFront())
authMW := service.NewAPIKeyAuthMiddleware(keyProvider)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authMW.ServeHTTP(w, r, mux.ServeHTTP)
}))
t.Cleanup(ts.Close)
t.Cleanup(func() { svc.DrainConnections(time.Millisecond, true) })
return &endpointStack{t: t, ts: ts, svc: svc}
}
func (s *endpointStack) wsURL() string {
return "ws" + strings.TrimPrefix(s.ts.URL, "http") + "/agent"
}
func (s *endpointStack) startWorker(target string, deployment string, endpoints []*livekit.AgentHttp_AgentEndpoint) *client.Worker {
w := client.New(client.Config{
ServerURL: s.wsURL(),
APIKey: testKey,
APISecret: testSecret,
AgentName: "test-agent",
Deployment: deployment,
Endpoints: endpoints,
TargetAddr: strings.TrimPrefix(target, "http://"),
})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
require.NoError(s.t, w.Start(ctx))
s.t.Cleanup(w.Close)
return w
}
func (s *endpointStack) clientToken(t *testing.T) string {
at := auth.NewAccessToken(testKey, testSecret).SetVideoGrant(&auth.VideoGrant{RoomJoin: true, Room: "x"})
tok, err := at.ToJWT()
require.NoError(t, err)
return tok
}
func httpEP(path string, methods []string, public bool) *livekit.AgentHttp_AgentEndpoint {
return &livekit.AgentHttp_AgentEndpoint{Path: path, Methods: methods, Public: public}
}
// newTargetApp is the local app the worker bridges into; it never listens on a
// port reachable through the stack, only via the tunnel.
func newTargetApp(t *testing.T, mux *http.ServeMux) *httptest.Server {
app := httptest.NewServer(mux)
t.Cleanup(app.Close)
return app
}
func TestAgentEndpointsCorrectnessGate(t *testing.T) {
bigDown := make([]byte, 4<<20)
_, _ = rand.Read(bigDown)
mux := http.NewServeMux()
mux.HandleFunc("GET /json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
})
mux.HandleFunc("POST /upload", func(w http.ResponseWriter, r *http.Request) {
sum := sha256.New()
n, err := io.Copy(sum, r.Body)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
fmt.Fprintf(w, "%d:%x", n, sum.Sum(nil))
})
mux.HandleFunc("GET /big", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(bigDown)
})
mux.HandleFunc("GET /sse", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
f := w.(http.Flusher)
for i := 0; i < 5; i++ {
fmt.Fprintf(w, "data: event-%d\n\n", i)
f.Flush()
time.Sleep(150 * time.Millisecond)
}
})
app := newTargetApp(t, mux)
stack := newEndpointStack(t, agent.EndpointsConfig{})
stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{
httpEP("/json", []string{"GET"}, true),
httpEP("/upload", []string{"POST"}, true),
httpEP("/big", []string{"GET"}, true),
httpEP("/sse", []string{"GET"}, true),
})
base := stack.ts.URL + "/agents/production"
t.Run("json round trip", func(t *testing.T) {
resp, err := http.Get(base + "/json")
require.NoError(t, err)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
require.JSONEq(t, `{"ok":true}`, string(body))
})
t.Run("upload byte exact", func(t *testing.T) {
up := make([]byte, 8<<20)
_, _ = rand.Read(up)
resp, err := http.Post(base+"/upload", "application/octet-stream", bytes.NewReader(up))
require.NoError(t, err)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
require.Equal(t, fmt.Sprintf("%d:%x", len(up), sha256.Sum256(up)), string(body))
})
t.Run("download byte exact", func(t *testing.T) {
resp, err := http.Get(base + "/big")
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
require.NoError(t, err)
require.True(t, bytes.Equal(bigDown, body))
})
t.Run("sse events arrive one at a time", func(t *testing.T) {
start := time.Now()
resp, err := http.Get(base + "/sse")
require.NoError(t, err)
defer resp.Body.Close()
br := bufio.NewReader(resp.Body)
var arrivals []time.Duration
for {
line, err := br.ReadString('\n')
if err != nil {
break
}
if strings.HasPrefix(line, "data:") {
arrivals = append(arrivals, time.Since(start))
}
}
require.Len(t, arrivals, 5)
// incremental delivery: the first event arrives well before the last is
// even written (5 x 150ms); buffering the whole body would collapse gaps
require.Less(t, arrivals[0], 450*time.Millisecond)
require.Greater(t, arrivals[4]-arrivals[0], 300*time.Millisecond)
})
t.Run("32 concurrent requests", func(t *testing.T) {
errCh := make(chan error, 32)
for i := 0; i < 32; i++ {
go func() {
resp, err := http.Get(base + "/json")
if err == nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 {
err = fmt.Errorf("status %d", resp.StatusCode)
}
}
errCh <- err
}()
}
for i := 0; i < 32; i++ {
require.NoError(t, <-errCh)
}
})
}
func TestAgentEndpointsStatusMapping(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
app := newTargetApp(t, mux)
stack := newEndpointStack(t, agent.EndpointsConfig{})
stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{
httpEP("/hook", []string{"POST"}, true),
httpEP("/private", []string{"GET"}, false),
})
base := stack.ts.URL + "/agents/production"
t.Run("404 unknown path", func(t *testing.T) {
resp, _ := http.Get(base + "/nope")
resp.Body.Close()
require.Equal(t, 404, resp.StatusCode)
})
t.Run("405 wrong method", func(t *testing.T) {
resp, _ := http.Get(base + "/hook")
resp.Body.Close()
require.Equal(t, 405, resp.StatusCode)
})
t.Run("401 non-public without token", func(t *testing.T) {
resp, _ := http.Get(base + "/private")
resp.Body.Close()
require.Equal(t, 401, resp.StatusCode)
})
t.Run("200 non-public with token", func(t *testing.T) {
req, _ := http.NewRequest("GET", base+"/private", nil)
req.Header.Set("Authorization", "Bearer "+stack.clientToken(t))
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
})
t.Run("503 unknown deployment", func(t *testing.T) {
resp, _ := http.Get(stack.ts.URL + "/agents/staging/hook")
resp.Body.Close()
require.Equal(t, 503, resp.StatusCode)
})
t.Run("307 slash redirect", func(t *testing.T) {
c := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := c.Post(base+"/hook/", "text/plain", nil)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, 307, resp.StatusCode)
require.Equal(t, "/agents/production/hook", resp.Header.Get("Location"))
})
}
func TestAgentEndpointsHOL(t *testing.T) {
// one data conn forces every stream onto the same socket: the credit windows
// and the write scheduler are the only things standing between a stalled
// reader and its siblings
blocked := make(chan struct{})
mux := http.NewServeMux()
mux.HandleFunc("GET /drip", func(w http.ResponseWriter, r *http.Request) {
f := w.(http.Flusher)
buf := make([]byte, 64<<10)
for {
if _, err := w.Write(buf); err != nil {
return
}
f.Flush()
select {
case <-r.Context().Done():
return
default:
}
}
})
mux.HandleFunc("GET /quick", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
})
app := newTargetApp(t, mux)
stack := newEndpointStack(t, agent.EndpointsConfig{DataConnCount: 1})
stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{
httpEP("/drip", []string{"GET"}, true),
httpEP("/quick", []string{"GET"}, true),
})
base := stack.ts.URL + "/agents/production"
// a stalled client: open /drip, read a little, then stop reading entirely
resp, err := http.Get(base + "/drip")
require.NoError(t, err)
defer resp.Body.Close()
small := make([]byte, 1024)
_, err = io.ReadFull(resp.Body, small)
require.NoError(t, err)
// do not read further; the stream's credit window fills and stays full
close(blocked)
time.Sleep(500 * time.Millisecond)
// sibling streams on the SAME connection must proceed at full speed
for i := 0; i < 5; i++ {
start := time.Now()
q, err := http.Get(base + "/quick")
require.NoError(t, err)
body, _ := io.ReadAll(q.Body)
q.Body.Close()
require.Equal(t, "ok", string(body))
require.Less(t, time.Since(start), 2*time.Second,
"sibling stream stalled behind a blocked heavy stream")
}
}
func TestAgentEndpointsRetrySafety(t *testing.T) {
var hits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("GET /json", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
_, _ = w.Write([]byte("ok"))
})
app := newTargetApp(t, mux)
// a worker whose local app is unreachable REFUSES streams; the front must
// retry on the healthy worker exactly once
stack := newEndpointStack(t, agent.EndpointsConfig{})
deadTarget := "127.0.0.1:1" // nothing listens
eps := []*livekit.AgentHttp_AgentEndpoint{httpEP("/json", []string{"GET"}, true)}
broken := client.New(client.Config{
ServerURL: stack.wsURL(), APIKey: testKey, APISecret: testSecret,
AgentName: "test-agent", Deployment: "production",
Endpoints: eps, TargetAddr: deadTarget,
})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
require.NoError(t, broken.Start(ctx))
t.Cleanup(broken.Close)
stack.startWorker(app.URL, "production", eps)
// run enough requests that both workers get picked first sometimes
okCount := 0
for i := 0; i < 12; i++ {
resp, err := http.Get(stack.ts.URL + "/agents/production/json")
require.NoError(t, err)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == 200 && string(body) == "ok" {
okCount++
}
}
require.Equal(t, 12, okCount, "REFUSED streams must fall through to the healthy worker")
require.EqualValues(t, 12, hits.Load())
}
func TestAgentEndpointsTruncationAborts(t *testing.T) {
// a worker connection dying mid-response must abort the client connection,
// never expose a clean-looking short body
mux := http.NewServeMux()
release := make(chan struct{})
mux.HandleFunc("GET /partial", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "1000000")
_, _ = w.Write(make([]byte, 1000))
w.(http.Flusher).Flush()
<-release
})
app := newTargetApp(t, mux)
stack := newEndpointStack(t, agent.EndpointsConfig{})
w := stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{
httpEP("/partial", []string{"GET"}, true),
})
resp, err := http.Get(stack.ts.URL + "/agents/production/partial")
require.NoError(t, err)
defer resp.Body.Close()
head := make([]byte, 1000)
_, err = io.ReadFull(resp.Body, head)
require.NoError(t, err)
w.Close() // kill the worker mid-stream
close(release)
_, err = io.ReadAll(resp.Body)
require.Error(t, err, "truncated response must not read as clean EOF")
}
func TestAgentEndpointsNoLocalListenerContract(t *testing.T) {
// the front never routes undeclared paths: the worker-local health/info
// routes are unreachable by construction
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("local")) })
app := newTargetApp(t, mux)
stack := newEndpointStack(t, agent.EndpointsConfig{})
stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{
httpEP("/declared", []string{"GET"}, true),
})
for _, path := range []string{"/", "/worker", "/undeclared"} {
resp, err := http.Get(stack.ts.URL + "/agents/production" + path)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, 404, resp.StatusCode, path)
}
}
// dial helper kept for upgrade tests once the SDK lands; avoids unused imports
var _ = net.Dialer{}
// the upgrade exchange and the raw bidirectional session both ride one stream;
// this guards the front's hijack path and the no-half-close rule for upgrades
func TestAgentEndpointsWebSocketUpgrade(t *testing.T) {
upgrader := websocket.Upgrader{}
mux := http.NewServeMux()
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer c.Close()
for {
mt, msg, err := c.ReadMessage()
if err != nil {
return
}
if err := c.WriteMessage(mt, append([]byte("echo:"), msg...)); err != nil {
return
}
}
})
app := newTargetApp(t, mux)
stack := newEndpointStack(t, agent.EndpointsConfig{})
stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{
{Path: "/ws", Kind: livekit.AgentHttp_AEK_WEBSOCKET, Public: true},
})
wsURL := "ws" + strings.TrimPrefix(stack.ts.URL, "http") + "/agents/production/ws"
c, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
require.NoError(t, err)
if resp != nil {
resp.Body.Close()
}
defer c.Close()
for i := 0; i < 5; i++ {
payload := fmt.Sprintf("msg-%d", i)
require.NoError(t, c.WriteMessage(websocket.TextMessage, []byte(payload)))
_, echoed, err := c.ReadMessage()
require.NoError(t, err)
require.Equal(t, "echo:"+payload, string(echoed))
}
// a larger frame exercises credit flow through the raw pump
big := bytes.Repeat([]byte("x"), 256<<10)
require.NoError(t, c.WriteMessage(websocket.BinaryMessage, big))
_, echoed, err := c.ReadMessage()
require.NoError(t, err)
require.Equal(t, len(big)+5, len(echoed))
}