Skip to content

feat(supervisor): add streaming HTTP request middleware evaluation #2431

Description

@pimlock

Problem Statement

Parent: #1733

The v1 HTTP request hook, SupervisorMiddleware.EvaluateHttpRequest, is unary. The supervisor buffers the whole request body, builds one HttpRequestEvaluation, and sends it to middleware. Request and replacement bodies are capped at 4 MiB.

That works for small, full-body inspection. It does not work for:

  • Middleware that decides from headers alone and should not force the body into a buffer.
  • Large uploads that should pass through unchanged, such as headers-only SigV4 signing.
  • Incremental inspection or transformation with backpressure.
  • Long-lived or unknown-length request bodies.
  • HTTP/2 and gRPC streams, which don't fit in one bounded unary payload.

HTTP/2 support (#2426) also needs middleware to run per logical HTTP stream, not on raw connection bytes. The streaming gRPC part of #2166 needs method, metadata, body, duration, rate, and cancellation controls without making middleware implement HTTP/2 framing.

Status

PR #4359 delivers the middleware contract half of this issue: the v2 HTTP hooks, for both requests and responses, over HTTP/1.x. The HTTP/2 half is still open. Today h2c and other traffic the supervisor can't parse reach v2 middleware only as an allow-or-refuse decision, described below.

Design (as implemented in #4359)

Contract

Two new bidirectional streaming RPCs share one set of event and result messages:

  • SupervisorMiddleware.EvaluateHttpRequestV2 for HTTP_REQUEST_V2 at PRE_CREDENTIALS.
  • SupervisorMiddleware.EvaluateHttpResponseV2 for HTTP_RESPONSE_V2 at PRE_RETURN.

The supervisor opens one stream per middleware stage and HTTP message. A request and its response use separate streams.

Each stream starts with a preflight carrying the head (target, headers, and status for responses), request context, the policy config, the permitted body modes, the modes OpenShell removed and why, the limits, and the declared body length when known. The stage answers with one of:

  • continue_without_body, with optional header mutations. The stage is done and later stages still see the body.
  • inspect with BUFFERED or STREAM.
  • reject.

The supervisor runs every stage's preflight in chain order before any stage gets a body byte.

Body modes

  • BUFFERED. One buffered_body with the full body and trailers, up to the stage's payload limit (max 4 MiB). The stage returns unchanged or a replacement, plus late header and trailer mutations. Nothing goes out until the whole chain approves.
  • STREAM. Ordered input_chunk events (64 KiB max each) and one input_end with trailers. The stage writes its own output: output_start, any number of output_chunk, then finish. Input and output chunk counts are independent. No total size or time limit. A stage that stalls for 30 seconds fails.

For responses, the supervisor removes modes it can't honour and says why: BODYLESS, PARTIAL, NO_TRANSFORM, ENCODED, TRUNCATION_UNDETECTABLE, OPEN_ENDED, OVER_LIMIT. For requests it removes BUFFERED when the declared length is over the stage limit.

Request delivery

Request output streams to the upstream while the sandbox is still uploading. The supervisor withholds output (up to 4 MiB) and sends it with the head when the route needs the whole body: a BUFFERED stage, SigV4 signing, request body credential rewrite, the forward proxy, JSON-RPC/MCP/GraphQL endpoints, HTTP/1.0, or Upgrade. Body-aware endpoints re-check every replaced body against policy before the next stage or the upstream sees it. Credentials are injected after all middleware mutations.

If a STREAM stage fails after the head reached the upstream, the supervisor closes that upstream connection. It never replays a partly forwarded request.

Traffic the supervisor can't inspect

For each entry that selects a connection the supervisor can't show as HTTP, and whose service binds HTTP_REQUEST_V2, the supervisor opens a request exchange with an uninspectable preflight. Reasons are TLS_SKIP, H2C, UNSUPPORTED_TUNNEL, RAW_TCP, and SQL_PASSTHROUGH. No body mode is offered. continue_without_body allows the connection and anything else refuses it. That's why a policy can now attach v2 request middleware to tls: skip endpoints.

Failure behaviour

v2 hooks always fail closed. The gateway rejects on_error: fail_open on entries whose service runs v2 hooks. Requests get 403 with middleware_denied or middleware_failed. Responses get 403/502 before the head is committed, and an aborted delivery plus a high-severity finding after.

Version selection and compatibility

We dropped the capability-advertisement idea from the original proposal. The binding operation picks the hook version. Gateways and supervisors that predate v2 don't know HTTP_REQUEST_V2/HTTP_RESPONSE_V2 and refuse the service at Describe, so a v2 service never gets called through a v1 RPC.

We also dropped the unary-to-stream adapter and the single shared chain runner:

  • v1 hooks keep their own engines and still work. They are deprecated for removal in 0.2.0, and the docs carry a migration guide.
  • One service implements one HTTP hook version. The gateway refuses a manifest with both.
  • One request or response chain can't mix versions. The gateway rejects policies whose overlapping selectors would do that, and the supervisor fails such a chain closed with middleware_hook_versions_mixed.

Remaining work: HTTP/2

#2426 tracks the transport gap: the L7 proxy only negotiates HTTP/1.1, so HTTP/2 needs tls: skip and skips L7 inspection. With v2 hooks, middleware can at least refuse that traffic, but it can't see it.

The v2 contract carries no HTTP/1-specific framing, so an HTTP/2 request stream should map onto the same exchange: one stream per stage and logical HTTP request. What's left:

  • Negotiate HTTP/1.1 or HTTP/2 for inspected traffic and relay HTTP/2 through the L7 path without tls: skip.
  • Run each HTTP/2 request stream through its own v2 hook sessions.
  • Keep multiplexing, flow control, trailers, and cancellation working, and keep a denial or timeout on one stream from killing the others.
  • Review the response mode rules for HTTP/2. TRUNCATION_UNDETECTABLE currently removes STREAM for anything that isn't HTTP/1.1.

The middleware API must not expose HTTP/2 frames, HPACK state, or stream IDs.

#2166

v2 hooks give the gRPC part of #2166 a base at the HTTP stream level: host, service, and method admission through authority and path, metadata limits, request byte, rate, duration, and concurrency limits, and cancellation and audit. gRPC message boundaries and protobuf parsing would need their own typed operation. Generic body chunks are not gRPC messages.

WebSocket middleware is tracked by #2428 and works the same under both hook versions.

Alternatives Considered

Advertise capabilities in the manifest. The original proposal. We picked binding operations instead. Old peers reject unknown operations, so there is no silent fallback, and there is nothing extra to negotiate.

Run v1 through an adapter on one shared runner. More moving parts in the supervisor to keep alive an API we plan to remove. Separate engines plus a no-mixing rule are simpler and easier to delete in 0.2.0.

Replace v1 right away. v1 was already public in 0.1.x. Deprecation with a migration guide gives services a release to move.

Add an HTTP/2-specific middleware API. Same request concepts as HTTP/1.x. Two APIs would duplicate chaining, transformation, and failure rules and leak transport details.

Keep buffering every request. Can't handle large uploads, long-lived streams, or headers-only processing.

Definition of Done

Covered by #4359. Check these off when it merges:

  • Reviewed design for the streaming hooks, body modes, lifecycle, and compatibility. RFC 0009 and the V2 HTTP Hooks docs page describe it.
  • Middleware can decide from headers without forcing the body into a buffer.
  • BUFFERED mode holds the body under a 4 MiB bound and sends no bytes before the chain approves.
  • STREAM mode has defined backpressure, transformation, rejection, and partial-disclosure behaviour.
  • Mode selection can't silently weaken enforcement. Removed modes come with a reason, and routes that need the full body withhold output.
  • v1 hooks keep working, with a deprecation date (0.2.0) and a migration guide.
  • Middleware can allow or refuse traffic the supervisor can't inspect.
  • Ordering, mutation validation, policy re-checks, findings, metadata, and timeouts stay consistent. v2 is always fail-closed.
  • Unit tests and the e2e:middleware-http-v2 suite cover v2 over HTTP/1.x. The v1 e2e still passes.

Still open:

  • The supervisor negotiates HTTP/1.1 or HTTP/2 for inspected traffic and relays HTTP/2 through the L7 path without tls: skip.
  • HTTP/1.x requests and HTTP/2 request streams use the same v2 hook sessions.
  • The HTTP/2 relay keeps multiplexing, flow control, trailers, cancellation, and isolation between streams.
  • Tests cover v2 hooks over HTTP/2.
  • Docs describe HTTP/2 behaviour.

Non-Goals

Activity

  1. added
    topic:l7Application-layer policy and inspection work
    on Jul 22, 2026
  2. github-actions commented on Aug 6, 2026

    @github-actions

    This issue has had no activity for 14 days and is now marked stale. It may be closed in 7 days if there is no further activity. Comment or remove the state:stale label to keep it open.

  3. removed
    state:staleInactive item at risk of automatic closure.
    on Sep 18, 2026
  4. pimlock commented on Sep 18, 2026

    @pimlock
    CollaboratorAuthor

    🏗️ build-plan

    Summary

    Replace the current unary and WIP four-mode HTTP body contracts with the approved two-mode, fail-closed protocol. PR #3450 will contain only the generic hook, runtime, migration, ordinary middleware consumers, tests, and documentation. SigV4 and Git signing move to two later built-in middleware PRs.

    Issue type: feat
    Complexity: High
    Confidence: Medium

    This intentionally breaks the pre-0.1 HTTP middleware API and closes #3307. It advances #2431, but does not close it because inspected HTTP/2 remains separate work.

    Stack and scope

    1. PR refactor(middleware)!: adopt two-mode HTTP body protocol #3450: shared HTTP hook and runtime.
    2. SigV4 built-in PR based on refactor(middleware)!: adopt two-mode HTTP body protocol #3450.
    3. Git-signing built-in PR based on the SigV4 branch.

    The base PR will restore the existing inline SigV4 implementation from main. It will remove the WIP SigV4 relocation, public POST_CREDENTIALS phase, and external Git-signing example. The later Git signer will be a trusted built-in, not a remote example.

    Ownership and boundaries

    • proto/supervisor_middleware.proto: shared HTTP event/result contract, binding capabilities, and protocol version.
    • openshell-supervisor-middleware: negotiation, validation, bounded BUFFERED handling, independent STREAM input/output pumps, chain composition, diagnostics, cancellation, and transport adapters.
    • openshell-supervisor-network: HTTP/1 request and response integration, commitment boundaries, body-aware policy gates, and bounded delivery. It must not spool middleware input or output to disk.
    • openshell-policy*: migration validation for HTTP fail_open.
    • Built-ins and examples: only regex and content-guard migration in this PR.
    • Existing inline SigV4 stays in openshell-supervisor-network until the stacked SigV4 PR.

    Implementation plan

    1. Replace request and response body messages with one shared lifecycle: Preflight, Begin, BUFFERED body/result, STREAM input/output, Finish, and Reject. Continue is the header-only success path. Preserve context, admitted target, config, authentication, diagnostics, findings, metadata, trailers, cancellation, and MiddlewareSessionEnd.
    2. Remove request/response sequence numbers, per-unit results, ownership acknowledgements, finalization counters, skip_remaining, OWNED_STREAM_BYTES, and lockstep STREAM_BYTES. Unknown or unset oneofs, unoffered modes, premature Finish, and RPC close without a terminal success fail closed.
    3. Add an explicit HTTP protocol version and supported body modes to MiddlewareBinding. Use a new HTTP evaluation RPC method path so old and new response contracts cannot decode each other accidentally. Reject missing or unsupported HTTP capabilities before activation.
    4. Implement request BUFFERED with bounded RAM and STREAM with independent, bounded input and output pumps. OpenShell keeps no replay original and creates no middleware tempfile. Enforce chunk limits, queue byte/message limits, total limits when present, idle/session deadlines, output-length checks, and cancellation.
    5. Compose STREAM and BUFFERED stages without deadlock. A later BUFFERED stage may keep consuming earlier output while it holds delivery. This base rollout will not offer late header mutations; the SigV4 stack will enable the trusted finalizer path. Later stages therefore see the final validated preflight head.
    6. Remove RequestBodySpool, MiddlewareRequestBody::Spool, ownership draining, and generic whole-chain tempfile paths. Keep body-aware GraphQL, JSON-RPC, and MCP checks before exposure by using the existing bounded in-memory policy gate or rejecting an incompatible combination.
    7. Use the same schema for responses. In refactor(middleware)!: adopt two-mode HTTP body protocol #3450, advertise Continue and BUFFERED only. Preserve bodyless, HEAD, encoded, range, no-transform, protected-header, framing, trailer, commitment, and cancellation behavior. Do not relabel the old lockstep response runner as duplex STREAM.
    8. Make HTTP middleware failures mandatory fail-closed. Keep the stored on_error field for migration. Accept empty or explicit fail_closed; reject fail_open with an actionable error when a selected implementation advertises an HTTP request or response binding. Preserve WebSocket-only fail_open behavior. Gateway interceptors are unchanged.
    9. Restore inline SigV4 ownership and LocalStack coverage from main. Remove the WIP built-in SigV4 move, its dependencies and docs, post_credentials.rs, and the public post-credentials phase. Remove examples/supervisor-middleware-git-signing/** and its task entries.
    10. Update regex, content guard, RFC 0009, architecture, published middleware and policy docs, and the related public skills. Run the agent-infrastructure consistency checks required by the maintenance map.

    Verification

    • Protocol negotiation, old/new rejection, unknown/unset events, unoffered modes, and diagnostics bounds.
    • BUFFERED empty, unchanged, replacement, deletion, overflow, unknown length, trailers, and forbidden late headers.
    • STREAM arbitrary input/output cardinality, output before EOF, delayed output start, empty input/output, premature or missing Finish, RPC close, and length mismatch.
    • Slow readers and writers, simultaneous backpressure, queue bounds, cancellation, disconnect, policy reload, early upstream response, and idle/session timeout.
    • STREAM to STREAM, STREAM to BUFFERED, BUFFERED to STREAM, and multiple BUFFERED stages.
    • Fixed-length, chunked, trailers, Expect: 100-continue, bodyless requests, and failure before versus after commitment.
    • GraphQL, JSON-RPC, and MCP validation before later-stage or upstream exposure.
    • HTTP fail_open migration rejection and WebSocket-only compatibility.
    • Response Continue/BUFFERED eligibility and proof that response STREAM is not offered.
    • No generic middleware tempfile or recovery-original path.
    • Inline SigV4 and LocalStack parity after the split.
    • Focused crate tests while iterating, then mise run test, mise run pre-commit, mise run ci, and the relevant Docker proxy E2E lane.

    Risks and decisions

    • Response STREAM is intentionally deferred until the response relay has the same independent-pump semantics. This is a visible pre-0.1 contract change and will be documented.
    • The shared attachment-level on_error field means a service that advertises both HTTP and WebSocket cannot use fail_open; a WebSocket-only service can.
    • Queue ceilings and timeout defaults will use existing platform limits unless live tests show they need a narrower bound.
    • The signer stacks may add trusted late-header authority later. The public remote hook remains credential-blind.

    Issue disposition


    Revision 2: replace the superseded four-mode design, remove signer work from the base PR, and define the three-PR stack.
    Revision 1: initial streaming request hook plan.

  5. github-actions commented on Oct 3, 2026

    @github-actions

    This issue has had no activity for 14 days and is now marked stale. It may be closed in 7 days if there is no further activity. Comment or remove the state:stale label to keep it open.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:supervisorProxy and routing-path workstate:staleInactive item at risk of automatic closure.topic:l7Application-layer policy and inspection work

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions