diff --git a/cmd/root/api.go b/cmd/root/api.go index 443438665b..f3aa9e8e5d 100644 --- a/cmd/root/api.go +++ b/cmd/root/api.go @@ -28,6 +28,7 @@ type apiFlags struct { recordPath string authToken string pprofAddr string + maxRequestSize int64 runConfig config.RuntimeConfig } @@ -47,6 +48,7 @@ func newAPICmd() *cobra.Command { cmd.PersistentFlags().StringVar(&flags.fakeResponses, "fake", "", "Replay AI responses from cassette file (for testing)") cmd.PersistentFlags().StringVar(&flags.recordPath, "record", "", "Record AI API interactions to cassette file") cmd.PersistentFlags().StringVar(&flags.authToken, "auth-token", "", "Bearer token required for API requests (empty = no authentication)") + cmd.PersistentFlags().Int64Var(&flags.maxRequestSize, "max-request-size", 1<<20, "Maximum request body size in bytes (default 1 MiB). Requests exceeding this limit are rejected with HTTP 413.") cmd.PersistentFlags().StringVar(&flags.pprofAddr, "pprof-addr", "", "TCP host:port to expose Go pprof endpoints at /debug/pprof/ (e.g. 127.0.0.1:6060); also set via CAGENT_PPROF_ADDR") _ = cmd.PersistentFlags().MarkHidden("pprof-addr") cmd.MarkFlagsMutuallyExclusive("fake", "record") @@ -132,7 +134,7 @@ func (f *apiFlags) runAPICommand(cmd *cobra.Command, args []string) (commandErr return fmt.Errorf("resolving agent sources: %w", err) } - s, err := server.New(ctx, sessionStore, &f.runConfig, time.Duration(f.pullIntervalMins)*time.Minute, sources, f.authToken) + s, err := server.New(ctx, sessionStore, &f.runConfig, time.Duration(f.pullIntervalMins)*time.Minute, sources, f.authToken, server.WithMaxRequestBytes(f.maxRequestSize)) if err != nil { return fmt.Errorf("creating server: %w", err) } diff --git a/docs/features/api-server/index.md b/docs/features/api-server/index.md index 5f1ea10559..966b70b0fc 100644 --- a/docs/features/api-server/index.md +++ b/docs/features/api-server/index.md @@ -192,6 +192,7 @@ docker agent serve api | [flags] | ------------------ | ---------------- | ------------------------------------------------ | | `-l, --listen` | `127.0.0.1:8080` | Address to listen on | | `--auth-token` | (none) | Bearer token required for all API requests. Leave empty to disable authentication (safe when listening on loopback interfaces only). Recommended when `--listen` binds to a network-reachable interface. | +| `--max-request-size ` | `1048576` (1 MiB) | Maximum request body size in bytes. Requests whose body exceeds this limit are rejected with HTTP 413 (Request Entity Too Large). | | `-s, --session-db` | `session.db` | Path to the SQLite session database | | `--pull-interval` | `0` (disabled) | Auto-pull OCI reference every N minutes | | `--fake` | (none) | Replay AI responses from cassette file (testing) | diff --git a/docs/features/cli/index.md b/docs/features/cli/index.md index b389d58730..976b0dd44d 100644 --- a/docs/features/cli/index.md +++ b/docs/features/cli/index.md @@ -282,6 +282,7 @@ $ docker agent serve api || [flags] | -------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------- | | `-l, --listen ` | `127.0.0.1:8080` | Address to listen on. | | `--auth-token ` | (none) | Bearer token required for all API requests. When set, every request must include `Authorization: Bearer `. Leave empty to disable authentication (safe when listening on loopback interfaces only). | +| `--max-request-size ` | `1048576` (1 MiB) | Maximum request body size. Requests exceeding this limit are rejected with HTTP 413. | | `-s, --session-db ` | `session.db` | Path to the SQLite session database (relative paths resolve against the working directory). | | `--pull-interval `| `0` | Periodically re-pull OCI/URL references and refresh the agent definition. `0` disables auto-pull. | | `--fake ` | (none) | Replay AI responses from a cassette file (for testing). Mutually exclusive with `--record`. | diff --git a/pkg/server/server.go b/pkg/server/server.go index a5754dbd96..a7f60a3b59 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -43,19 +43,42 @@ type Server struct { heartbeatInterval time.Duration } -func New(ctx context.Context, sessionStore session.Store, runConfig *config.RuntimeConfig, refreshInterval time.Duration, agentSources config.Sources, authToken string) (*Server, error) { - return NewWithManager(NewSessionManager(ctx, agentSources, sessionStore, refreshInterval, runConfig), authToken), nil +func New(ctx context.Context, sessionStore session.Store, runConfig *config.RuntimeConfig, refreshInterval time.Duration, agentSources config.Sources, authToken string, opts ...Option) (*Server, error) { + return NewWithManager(NewSessionManager(ctx, agentSources, sessionStore, refreshInterval, runConfig), authToken, opts...), nil } const defaultMaxRequestBytes int64 = 1 << 20 // 1 MiB +// Option configures a [Server] at construction time. +type Option func(*serverOptions) + +type serverOptions struct { + maxRequestBytes int64 +} + +// WithMaxRequestBytes sets the maximum request body size in bytes. Requests +// whose body exceeds the limit are rejected with HTTP 413. Zero or negative +// values fall back to the default (1 MiB). +func WithMaxRequestBytes(n int64) Option { + return func(o *serverOptions) { o.maxRequestBytes = n } +} + // NewWithManager builds a Server around an already-constructed SessionManager. // Useful when the runtime is owned by another component (e.g. the TUI) and // only needs to be exposed over HTTP. -func NewWithManager(sm *SessionManager, authToken string) *Server { +func NewWithManager(sm *SessionManager, authToken string, opts ...Option) *Server { + var o serverOptions + for _, opt := range opts { + opt(&o) + } + maxBytes := o.maxRequestBytes + if maxBytes <= 0 { + maxBytes = defaultMaxRequestBytes + } + e := echo.New() e.Use(echolog.RedactedRequestLogger()) - e.Use(middleware.BodyLimit(strconv.FormatInt(defaultMaxRequestBytes, 10))) + e.Use(middleware.BodyLimit(strconv.FormatInt(maxBytes, 10))) e.Use(echo.WrapMiddleware(upstream.Handler)) // Add bearer token middleware if token is configured diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index 523c6d8d32..ec475b2db3 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "net" "net/http" @@ -104,6 +105,67 @@ func TestServer_OversizedBodyRejected(t *testing.T) { assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) } +// TestServer_MaxRequestBytesOption verifies that WithMaxRequestBytes wires a +// custom body-size cap: bodies under the limit reach handlers normally, while +// bodies over the limit are rejected with 413 before any handler runs. +// +// The test targets POST /api/sessions/:id/messages because the issue (#3937) +// specifically calls out that route. With a nil SessionManager the handler +// returns 400 ("message is required") for an under-limit request — any +// non-413 status confirms the body cap was not exceeded. +func TestServer_MaxRequestBytesOption(t *testing.T) { + t.Parallel() + + const bodyLimit = 16 + srv := NewWithManager(nil, "", WithMaxRequestBytes(bodyLimit)) + + cases := []struct { + name string + body string + want413 bool + }{ + {"under limit", `{}`, false}, + {"over limit", `{"message":{"role":"user","content":"exceeds the cap"}}`, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/sessions/abc/messages", strings.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.e.ServeHTTP(rec, req) + if tc.want413 { + assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) + } else { + // A body under the limit reaches the handler; addMessage returns + // 400 for an empty message before it touches the SessionManager. + assert.Equal(t, http.StatusBadRequest, rec.Code) + } + }) + } +} + +// TestServer_WithMaxRequestBytesZeroFallback verifies that zero and negative +// values fall back to the 1 MiB default. A body just over 1 MiB must still +// trigger 413 even when WithMaxRequestBytes received 0 or -1. +func TestServer_WithMaxRequestBytesZeroFallback(t *testing.T) { + t.Parallel() + + for _, n := range []int64{0, -1} { + t.Run(fmt.Sprintf("n=%d", n), func(t *testing.T) { + t.Parallel() + srv := NewWithManager(nil, "", WithMaxRequestBytes(n)) + // A body over the default 1 MiB cap must still be rejected. + body := bytes.Repeat([]byte("a"), int(defaultMaxRequestBytes)+1) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/sessions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.e.ServeHTTP(rec, req) + assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) + }) + } +} + func TestServer_ListSessions(t *testing.T) { t.Parallel()