mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Bound agent capability HTTP responses
Contract-Neutral: Enforces client-side response resource limits without changing the agent wire contract.
This commit is contained in:
@@ -9,6 +9,8 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -18,6 +20,9 @@ const (
|
||||
AgentAPITokenHeader = "X-API-Token"
|
||||
AgentSurfaceHeader = "X-Pulse-Agent-Surface"
|
||||
AgentSurfacePulseMCP = "pulse_mcp"
|
||||
|
||||
maxManifestResponseBodyBytes int64 = 1 << 20 // 1 MiB
|
||||
maxCapabilityResponseBodyBytes int64 = 16 << 20 // 16 MiB
|
||||
)
|
||||
|
||||
// HTTPDoer is the shared minimum interface for agent-surface HTTP clients.
|
||||
@@ -130,8 +135,12 @@ func FetchManifest(ctx context.Context, client HTTPDoer, baseURL string) (*Manif
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GET %s: status %d", AgentCapabilitiesPath, resp.StatusCode)
|
||||
}
|
||||
body, err := readBoundedResponseBody(resp, maxManifestResponseBodyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GET %s response: %w", AgentCapabilitiesPath, err)
|
||||
}
|
||||
var manifest Manifest
|
||||
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
|
||||
if err := json.Unmarshal(body, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("decode manifest: %w", err)
|
||||
}
|
||||
return &manifest, nil
|
||||
@@ -174,7 +183,7 @@ func CallCapabilityHTTP(ctx context.Context, client HTTPDoer, baseURL, token str
|
||||
return HTTPCallResponse{}, fmt.Errorf("%s %s: %w", cap.Method, projected.Path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readBoundedResponseBody(resp, maxCapabilityResponseBodyBytes)
|
||||
if err != nil {
|
||||
return HTTPCallResponse{}, fmt.Errorf("read %s %s response: %w", cap.Method, projected.Path, err)
|
||||
}
|
||||
@@ -187,6 +196,13 @@ func CallCapabilityHTTP(ctx context.Context, client HTTPDoer, baseURL, token str
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readBoundedResponseBody(resp *http.Response, limit int64) ([]byte, error) {
|
||||
if err := securityutil.LimitResponseBody(resp, limit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// CallCapabilityHTTPByName resolves and executes a named manifest capability
|
||||
// through the shared discovery/projection/auth header path.
|
||||
func CallCapabilityHTTPByName(ctx context.Context, client HTTPDoer, baseURL, token string, capabilities []Capability, name string, args map[string]any) (HTTPCallResponse, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -39,6 +40,27 @@ func TestFetchManifestUsesSharedDiscoveryPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchManifestRejectsDeclaredOversizedResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Length", fmt.Sprint(maxManifestResponseBodyBytes+1))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := FetchManifest(context.Background(), server.Client(), server.URL)
|
||||
assertResponseSizeLimitError(t, err, maxManifestResponseBodyBytes)
|
||||
}
|
||||
|
||||
func TestFetchManifestRejectsUndeclaredOversizedResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeUndeclaredOversizedResponse(w, maxManifestResponseBodyBytes)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := FetchManifest(context.Background(), server.Client(), server.URL)
|
||||
assertResponseSizeLimitError(t, err, maxManifestResponseBodyBytes)
|
||||
}
|
||||
|
||||
func TestBuildCapabilityHTTPRequestProjectsPathBodyAndAuth(t *testing.T) {
|
||||
cap := Capability{
|
||||
Name: SetOperatorStateCapabilityName,
|
||||
@@ -197,6 +219,68 @@ func TestCallCapabilityHTTPExecutesManifestProjection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallCapabilityHTTPRejectsDeclaredOversizedResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Length", fmt.Sprint(maxCapabilityResponseBodyBytes+1))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := CallCapabilityHTTP(context.Background(), server.Client(), server.URL, "token", Capability{
|
||||
Name: FleetContextCapabilityName,
|
||||
Method: http.MethodGet,
|
||||
Path: FleetContextCapabilityPath,
|
||||
}, nil)
|
||||
assertResponseSizeLimitError(t, err, maxCapabilityResponseBodyBytes)
|
||||
}
|
||||
|
||||
func TestCallCapabilityHTTPRejectsUndeclaredOversizedResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeUndeclaredOversizedResponse(w, maxCapabilityResponseBodyBytes)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := CallCapabilityHTTP(context.Background(), server.Client(), server.URL, "token", Capability{
|
||||
Name: FleetContextCapabilityName,
|
||||
Method: http.MethodGet,
|
||||
Path: FleetContextCapabilityPath,
|
||||
}, nil)
|
||||
assertResponseSizeLimitError(t, err, maxCapabilityResponseBodyBytes)
|
||||
}
|
||||
|
||||
func assertResponseSizeLimitError(t *testing.T, err error, limit int64) {
|
||||
t.Helper()
|
||||
want := fmt.Sprintf("response body exceeds %d bytes", limit)
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func writeUndeclaredOversizedResponse(w http.ResponseWriter, limit int64) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
prefix := []byte(`{"padding":"`)
|
||||
if _, err := w.Write(prefix); err != nil {
|
||||
return
|
||||
}
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
chunk := strings.Repeat("x", 32*1024)
|
||||
remaining := limit + 1 - int64(len(prefix))
|
||||
for remaining > 0 {
|
||||
writeSize := int64(len(chunk))
|
||||
if writeSize > remaining {
|
||||
writeSize = remaining
|
||||
}
|
||||
if _, err := io.WriteString(w, chunk[:int(writeSize)]); err != nil {
|
||||
return
|
||||
}
|
||||
remaining -= writeSize
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallCapabilityHTTPByNameResolvesAndExecutesManifestProjection(t *testing.T) {
|
||||
var got struct {
|
||||
Method string
|
||||
|
||||
Reference in New Issue
Block a user