Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cmd/root/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type apiFlags struct {
recordPath string
authToken string
pprofAddr string
maxRequestSize int64
runConfig config.RuntimeConfig
}

Expand All @@ -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.")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Help-string double default.

(default 1 MiB) renders alongside Cobra's auto-appended (default 1048576). You already called this out as intentional for consistency with serve chat (cmd/root/chat.go:51) — agreed, no change needed. Noting it only so the next reader doesn't re-file it.

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")
Expand Down Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions docs/features/api-server/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ docker agent serve api <agent-file>|<agents-dir> [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 <bytes>` | `1048576` (1 MiB) | Maximum request body size in bytes. Requests whose body exceeds this limit are rejected with HTTP 413 (Request Entity Too Large). |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] The canonical CLI reference is missing this flag.

docs/features/cli/index.md carries a per-command flags table, and the serve api table (under ### docker agent serve api, lines 282–292 on main) still lacks --max-request-size. The precedent set by serve chat — the very flag this PR mirrors — documents it in both places:

  • docs/features/cli/index.md:396
  • docs/features/chat-server/index.md:198

This PR updates only this file, so the CLI reference now under-documents serve api relative to serve chat. Please add a matching row to the serve api flags table in docs/features/cli/index.md.

| `-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) |
Expand Down
1 change: 1 addition & 0 deletions docs/features/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ $ docker agent serve api <agent-file>|<agents-dir>|<registry-ref> [flags]
| -------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------- |
| `-l, --listen <addr>` | `127.0.0.1:8080` | Address to listen on. |
| `--auth-token <token>` | (none) | Bearer token required for all API requests. When set, every request must include `Authorization: Bearer <token>`. Leave empty to disable authentication (safe when listening on loopback interfaces only). |
| `--max-request-size <bytes>` | `1048576` (1 MiB) | Maximum request body size. Requests exceeding this limit are rejected with HTTP 413. |
| `-s, --session-db <path>` | `session.db` | Path to the SQLite session database (relative paths resolve against the working directory). |
| `--pull-interval <minutes>`| `0` | Periodically re-pull OCI/URL references and refresh the agent definition. `0` disables auto-pull. |
| `--fake <path>` | (none) | Replay AI responses from a cassette file (for testing). Mutually exclusive with `--record`. |
Expand Down
31 changes: 27 additions & 4 deletions pkg/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +74 to +77

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Duplicated default/fallback logic across the two servers.

This repeats the defaultMaxRequestBytes constant and the <= 0 fallback that pkg/chatserver/server.go already has (:104, :226-228). Acceptable as-is since these are independent packages — flagging only in case you'd prefer a small shared helper so the two can't drift.


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
Expand Down
62 changes: 62 additions & 0 deletions pkg/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
Expand Down Expand Up @@ -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()

Expand Down
Loading