Skip to content

feat(supervisor): add HTTP response pre-return middleware evaluation #2691

Description

@pimlock

User story

As an operator, I want attached supervisor middleware to inspect, transform, or block HTTP responses before the sandbox receives them.

Problem statement

Supervisor middleware supports HTTP_REQUEST/PRE_CREDENTIALS, but it has no HTTP_RESPONSE/PRE_RETURN hook. OpenShell therefore forwards upstream responses without running attached response policy. Asynchronous inspection cannot enforce this hook because it cannot recall bytes already delivered to the sandbox.

Parent: #1733.

Impact / why this matters

Operators cannot apply generic response controls such as PII detection, redaction, secret scanning, or compliance filtering in the configured middleware chain. The current workaround requires another proxy and loses OpenShell's policy order, failure behavior, and audit context.

Proposed design

Add an HTTP_RESPONSE/PRE_RETURN binding and a hook-specific bidirectional inspection API. The API models HTTP response semantics directly rather than reusing the request contract.

Each selected stage receives a preflight with the final response status, safe headers, request context, stage-local body-mode choices, and supervisor limits. It may skip, inspect, or block_delivery. An inspecting stage selects headers-only processing, one bounded whole-body value, or normalized response-body units. Every body unit has a stage-local sequence and end_of_stream marks the final result-bearing unit.

Body-inspecting stages that complete normally receive one normalized trailer event, including when the trailer set is empty, and return one trailer result. V1 can modify or remove existing trailer names but cannot create a name absent from the event.

block_delivery is a successful policy decision, not a middleware failure. Before response commitment, OpenShell returns its canonical 403 middleware_denied response. After commitment, OpenShell aborts downstream delivery. The upstream request has already run, so blocking its response does not reject or roll back that request.

STREAM_BYTES is unit-local in V1: every result accounts for its complete input unit, and middleware cannot retain input across units. skip_remaining finalizes the current unit and ends inspection for that stage while later stages continue. Middleware failures still follow on_error. Fail-open continues from the last input OpenShell still owns; fail-closed returns a clean error before commitment and aborts after commitment.

Policy attachment remains unchanged. Attaching middleware activates every operation and phase advertised by its manifest.

This issue includes the raw hook protocol and one response-capable example. A middleware-author SDK or callback adapter is follow-up work.

The build-plan comment defines the complete contract and implementation details.

Acceptance criteria

  • An attached service can advertise HTTP_RESPONSE/PRE_RETURN and execute it through HttpResponsePreReturn.Evaluate using the finalized V1 message names.
  • Preflight supports skip, inspect, and block_delivery. Result-envelope diagnostics apply to every action and invalid diagnostics follow on_error.
  • OpenShell derives permitted_body_modes from the original final response head and each stage's effective payload limit. Different stages may receive different lists.
  • HTTP request and response middleware can write permitted end-to-end headers without an x-openshell-middleware- namespace restriction. Both paths use one shared validator and atomic applicator with direction-specific protected fields.
  • V1 supports headers-only inspection, bounded complete-body inspection, and normalized lockstep byte streaming.
  • Every normally completed body inspection receives one final result-bearing unit with end_of_stream = true, including a present empty sequence-1 unit for an empty body-capable response.
  • Every body input receives exactly one ordered pass_through, transform, block_delivery, or skip_remaining result with the matching stage-local sequence.
  • STREAM_BYTES is unit-local. Every result fully accounts for the current input, and V1 does not permit cross-unit retention.
  • skip_remaining ends body and trailer events only for that stage. The current and later units continue through later stages.
  • A normally completed whole-body or streaming stage receives one trailer event after its final body result, including for an empty trailer set, and returns one trailer result.
  • V1 trailer results may modify or remove names present in the trailer event. They cannot introduce a missing name.
  • OpenShell does not expose HTTP/1 transfer chunks or socket-read boundaries to middleware.
  • OpenShell repairs downstream framing after transformation and excludes protocol upgrades from generic response-body processing.
  • OpenShell relays interim 1xx responses unchanged and evaluates only the final non-1xx response head.
  • Before commitment, block_delivery returns the canonical 403 middleware_denied; HEAD receives the same headers and content length without a body. After commitment, OpenShell aborts delivery and does not reuse upstream.
  • Before commitment, fail-closed middleware failure returns the canonical 502 response_delivery_failed; HEAD receives the same headers and content length without a body. After commitment, fail-closed aborts delivery.
  • STREAM_BYTES inputs are at most min(64 KiB, max_payload_bytes). Each result uses the effective binding timeout and the existing 30-second per-unit chain cap. Streaming has no total response-lifetime deadline.
  • Whole-body capacity failures use whole_body_over_capacity. A dedicated whole-body accumulation deadline and its configuration remain required before the response dispatcher lands.
  • Shared terminal reasons distinguish sandbox and upstream disconnects.
  • Tests cover ordered and mixed-profile chains, bodyless responses, content-length, chunked and close-delimited bodies, replacement, whole-body overflow, non-identity content encoding, partial responses, original no-transform, stale integrity metadata, trailers, block delivery, unit-local streaming, skip remaining, timeouts, directional disconnects, and policy reload.
  • OCSF events omit response content, protected headers, and free-form middleware reasons.

Alternatives considered

Use one universal inspection RPC

HTTP requests, HTTP responses, and WebSocket messages have different heads, boundaries, mutation rules, commitment points, and denial behavior. A universal event union would move those rules out of the interface and into prose and runtime validation. The implementation may share flow-control machinery, but the public API remains hook-specific.

Observe responses asynchronously

This preserves latency but permits partial disclosure, so it cannot enforce PRE_RETURN.

Add only a unary buffered response RPC

This cannot support SSE, long-lived responses, or later rolling-window processing without introducing another API.

Reuse EvaluateHttpRequest

Requests and responses differ in status, headers, trailers, framing, mutation authority, and commitment. They need separate contracts.

Activity

  1. added
    topic:l7Application-layer policy and inspection work
    on Aug 10, 2026
  2. github-actions commented on Aug 25, 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. pimlock commented on Aug 31, 2026

    @pimlock
    CollaboratorAuthor

    🏗️ build-plan

    Implementation plan

    Issue type: feat
    Complexity: High
    Confidence: Medium. V1 is lockstep and bounded, but response commitment, framing repair, trailers, blocking, and mixed middleware chains need integration coverage.

    Summary

    Add HTTP_RESPONSE/PRE_RETURN through a hook-specific bidirectional gRPC API. Each selected middleware stage receives its own ordered response stream after OpenShell receives the final upstream response head and before it delivers that response to the sandbox.

    V1 supports preflight, response-header mutation, headers-only evaluation, one bounded whole-body value, lockstep byte streaming, response blocking, and response trailers. Every body input has a stage-local sequence number and receives exactly one result. The final result-bearing body unit sets end_of_stream = true.

    STREAM_BYTES is unit-local in V1. Every result accounts for its complete input unit, and middleware cannot retain input across units. A stage may use skip_remaining to finalize the current unit and stop its own body inspection while later stages continue.

    The event and result envelopes remain additive. Later work can add ownership transfer, asynchronous output, input finalization checkpoints, semantic SSE events, and creation of new trailer names without changing RPC cardinality.

    Policy attachment stays unchanged. Attaching middleware activates every operation and phase advertised by its service manifest. This issue does not add a policy-side hook selector.

    This issue defines the raw hook protocol and adds one response-capable built-in or example. A middleware-author SDK or callback adapter is follow-up work.

    Public contract

    Operation and service

    1. Add HTTP_RESPONSE to SupervisorMiddlewareOperation and accept the exact HTTP_RESPONSE/PRE_RETURN binding.

    2. Use the V1 service and RPC names below.

      service HttpResponsePreReturn {
        rpc Evaluate(stream HttpResponseEvent)
            returns (stream HttpResponseEventResult);
      }
    3. Serve this phase-specific service alongside the existing SupervisorMiddleware discovery and configuration service. Do not add a required method to SupervisorMiddleware.

    4. Use RequestContext.request_id as the exchange identity across request and response evaluation. Do not keep request evaluation open while waiting for the response.

    5. Open one ordered stream per selected middleware stage and HTTP response.

    Event envelopes

    message HttpResponseEvent {
      oneof event {
        HttpResponsePreflight preflight = 1;
        HttpResponseBodyUnit body = 2;
        MiddlewareSessionEnd session_end = 3;
        HttpResponseTrailers trailers = 4;
      }
    }
    
    message HttpResponseEventResult {
      oneof result {
        HttpResponsePreflightResult preflight_result = 1;
        HttpResponseBodyResult body_result = 2;
        HttpResponseTrailersResult trailers_result = 3;
      }
    }

    The outer oneof leaves room for later output and finalization messages. V1 does not assign field numbers or wire semantics to those deferred messages.

    Preflight

    message HttpResponsePreflight {
      RequestContext context = 1;
      HttpRequestTarget target = 2;
      uint32 status_code = 3;
      repeated HttpHeader headers = 4;
      string middleware_name = 5;
      google.protobuf.Struct config = 6;
      uint64 max_payload_bytes = 7;
      repeated HttpResponseBodyMode permitted_body_modes = 8;
    }
    
    message HttpResponsePreflightResult {
      oneof action {
        HttpResponsePreflightSkip skip = 1;
        HttpResponsePreflightInspect inspect = 2;
        HttpResponseBlockDelivery block_delivery = 7;
      }
      string reason = 3;
      string reason_code = 4;
      repeated Finding findings = 5;
      map<string, string> metadata = 6;
    }
    
    message HttpResponsePreflightSkip {}
    
    message HttpResponsePreflightInspect {
      HttpResponseBodyMode body_mode = 1;
      repeated HeaderMutation header_mutations = 2;
    }
    
    message HttpResponseBlockDelivery {}
    1. Send preflight once with the stable exchange identity, bounded request context, admitted target, final upstream status, safe response headers, middleware name, validated configuration, effective payload limit, and stage-local body-mode list.
    2. The middleware returns exactly one skip, inspect, or block_delivery action.
    3. skip ends only that stage for this response. It succeeds without invoking on_error.
    4. inspect selects one listed body mode and may include permitted response-header mutations. OpenShell validates the complete result atomically before opening the next stage.
    5. block_delivery is a successful policy decision enforced independently of on_error. Invalid block results remain middleware failures.
    6. Diagnostic fields sit on the result envelope and apply to every action. Invalid diagnostics invalidate the complete result and follow on_error.
    7. reason is limited to 4 KiB and never reaches the sandbox or security logs. reason_code uses the HttpRequestResult.reason_code format and 64-byte limit. OpenShell returns it to the sandbox only for an accepted pre-commit block.
    8. Findings and metadata keep the existing audit-safe limits.

    Stage-local body modes

    enum HttpResponseBodyMode {
      HTTP_RESPONSE_BODY_MODE_UNSPECIFIED = 0;
      HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY = 1;
      HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES = 2;
      HTTP_RESPONSE_BODY_MODE_STREAM_BYTES = 3;
    }
    1. Determine response-shape eligibility from the original final response head before applying middleware mutations.
    2. Apply each stage's effective max_payload_bytes after response-shape filtering. Stages with different limits may receive different permitted_body_modes lists.
    3. HEADERS_ONLY is always present and is the only mode for bodyless, partial, encoded, or original no-transform responses.
    4. For an otherwise eligible known-length response, omit WHOLE_BODY_BYTES when its body is larger than the current stage's limit.
    5. An eligible unknown-length response may select WHOLE_BODY_BYTES and later fail with whole_body_over_capacity through that stage's on_error.
    6. Omit STREAM_BYTES when max_payload_bytes is zero.
    7. Selecting an unlisted mode fails according to on_error. OpenShell does not silently downgrade it.

    Shared HTTP header authority

    1. HTTP request and response middleware may write or remove syntactically valid end-to-end fields exposed in the sanitized head. Neither direction requires an x-openshell-middleware- prefix.
    2. Reject writes and removals of credential, routing, framing, coding, range, hop-by-hop, and connection-nominated fields as required by the direction-specific profile.
    3. For response headers, reject writes but allow removal of Accept-Ranges, ETag, Content-MD5, Digest, Content-Digest, Repr-Digest, Signature, and Signature-Input.
    4. Use one shared validator and atomic applicator. It enforces token syntax, safe values, on_existing, mutation counts, encoded-byte limits, connection-nominated fields, and the selected authority profile.
    5. A later stage sees the accepted head from earlier stages. Relay code may replay only mutations accepted by the shared validator.

    Body input and completion

    message HttpResponseBodyUnit {
      uint64 sequence = 1;
      oneof payload {
        bytes data = 2;
      }
      bool end_of_stream = 3;
    }
    1. Sequence numbers are contiguous and stage-local, starting at 1.
    2. WHOLE_BODY_BYTES sends exactly one final body unit for every body-capable response. A zero-length representation uses present empty data at sequence 1 with end_of_stream = true.
    3. STREAM_BYTES sends one or more units to a stage that completes normally. Exactly one has end_of_stream = true.
    4. OpenShell does not read ahead, so it may send an empty final unit after the last nonempty unit.
    5. A stage ended by skip_remaining, block, or failure receives no later final unit.
    6. Decode HTTP transfer coding before middleware. Unit boundaries carry no HTTP or application meaning.
    7. A streaming input unit is at most min(64 KiB, max_payload_bytes). OpenShell may send shorter units to preserve flushing and backpressure.
    8. STREAM_BYTES has no total response-lifetime deadline.

    Body result

    message HttpResponseBodyResult {
      uint64 sequence = 1;
      oneof action {
        HttpResponseBodyPassThrough pass_through = 2;
        HttpResponseBodyTransform transform = 3;
        HttpResponseBlockDelivery block_delivery = 8;
        HttpResponseBodySkipRemaining skip_remaining = 9;
      }
      string reason = 4;
      string reason_code = 5;
      repeated Finding findings = 6;
      map<string, string> metadata = 7;
    }
    
    message HttpResponseBodySkipRemaining {
      oneof current {
        HttpResponseBodyPassThrough pass_through = 1;
        HttpResponseBodyTransform transform = 2;
      }
    }
    
    message HttpResponseBodyTransform {
      oneof replacement {
        bytes data = 1;
      }
    }
    1. Require exactly one result for every body input, including the final unit.
    2. Results must match the next outstanding sequence. Reject zero, gaps, duplicates, and regressions.
    3. pass_through forwards the input unchanged.
    4. transform replaces the complete input. A present empty replacement deletes it.
    5. block_delivery stops response delivery under the shared block contract.
    6. skip_remaining finalizes the current unit with a nested pass-through or transform, then ends body and trailer events for this stage. The current and later units continue through later stages.
    7. Every result fully accounts for the current input unit. V1 does not permit middleware to retain input for a later unit.
    8. OpenShell retains the current input until it validates the result. Fail-open can therefore bypass a failed stage using the last input OpenShell still owns.
    9. Diagnostic fields remain on the body-result envelope and follow the same validation rules as preflight diagnostics.

    Trailers

    message HttpResponseTrailers {
      repeated HttpHeader headers = 1;
    }
    
    message HttpResponseTrailersResult {
      repeated HeaderMutation trailer_mutations = 1;
      string reason = 2;
      string reason_code = 3;
      repeated Finding findings = 4;
      map<string, string> metadata = 5;
    }
    1. After the final body result, send exactly one trailer event to every WHOLE_BODY_BYTES or STREAM_BYTES stage that completes normally. Send it even when the current trailer set is empty.
    2. Do not send trailers to skip, HEADERS_ONLY, semantically bodyless responses, or stages ended by block, failure, or skip_remaining.
    3. Require one explicit trailer result. An empty mutation list preserves the current set.
    4. Preserve wire order and repeated names as separate entries.
    5. Validate mutations atomically in chain order. Writes may target only a case-insensitive name present in the trailer event. V1 cannot create a missing trailer name. Removing an absent name is a no-op.
    6. Protect credential, routing, framing, coding, range, hop-by-hop, and connection-nominated fields.
    7. Apply envelope diagnostics for empty and nonempty mutation lists. Invalid diagnostics or mutations invalidate the complete result and follow on_error.

    Block delivery

    1. Evaluate results in policy order. Once OpenShell accepts a valid block, stop later middleware evaluation and end every still-writable opened stage with MIDDLEWARE_DENIAL.
    2. A failure handled earlier may already have stopped evaluation. A later block does not override it.
    3. Before response commitment, including preflight and WHOLE_BODY_BYTES, replace the upstream response with the canonical 403 Forbidden middleware-denial response. Its JSON body has error: middleware_denied and includes a validated reason_code when supplied. Never return free-form reason.
    4. For HEAD, send the canonical headers and content length without the JSON body.
    5. Close the downstream connection after the pre-commit denial response.
    6. After response commitment, including STREAM_BYTES, abort downstream delivery. Do not add an error body, terminating chunk, or error trailer. Do not reuse the upstream connection.
    7. The upstream request has already run. Blocking its response does not reject or roll it back.

    Session termination

    enum MiddlewareSessionEndReason {
      MIDDLEWARE_SESSION_END_REASON_UNSPECIFIED = 0;
      MIDDLEWARE_SESSION_END_REASON_NORMAL = 1;
      MIDDLEWARE_SESSION_END_REASON_DOWNSTREAM_DISCONNECT = 2;
      MIDDLEWARE_SESSION_END_REASON_POLICY_RELOAD = 3;
      MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_DENIAL = 4;
      MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5;
      MIDDLEWARE_SESSION_END_REASON_PROTOCOL_ERROR = 6;
      MIDDLEWARE_SESSION_END_REASON_CANCELLATION = 7;
      MIDDLEWARE_SESSION_END_REASON_UPSTREAM_FAILURE = 8;
      MIDDLEWARE_SESSION_END_REASON_POLICY_DENIAL = 9;
      MIDDLEWARE_SESSION_END_REASON_STAGE_SKIPPED = 10;
      MIDDLEWARE_SESSION_END_REASON_UPSTREAM_DISCONNECT = 11;
    }
    
    message MiddlewareSessionEnd {
      MiddlewareSessionEndReason reason = 1;
      MiddlewareSessionProtocolError protocol_error = 2;
    }

    Send at most one best-effort session_end. It receives no result. DOWNSTREAM_DISCONNECT means the sandbox peer disconnected. UPSTREAM_DISCONNECT means upstream disconnected after a valid response or upgrade. UPSTREAM_FAILURE means upstream rejected or failed before that point.

    Chain behavior

    1. Snapshot the selected chain and policy generation for the response.
    2. Run preflight in ascending policy order. Each stage sees the head after accepted mutations from earlier stages.
    3. Apply each complete preflight result atomically before opening the next stage.
    4. Remove skip and HEADERS_ONLY stages from body processing after preflight.
    5. Give every body-processing stage an independent contiguous sequence space.
    6. A stage's output becomes the next stage's input with fresh sequence numbers at that boundary.
    7. Process each bounded streaming unit through every active stage before releasing the final representation to the sandbox.
    8. A WHOLE_BODY_BYTES stage creates a bounded barrier. Mixed chains may buffer before that stage and rechunk its output for a later streaming stage.
    9. Preserve global policy order and validate results before exposing mutations or output to the next stage.
    10. Retain the current input representation until its result is validated so fail-open can bypass a failed stage.
    11. skip_remaining ends only the current stage. Later stages still evaluate the current unit and remaining suffix.
    12. If a stage fails open, keep its accepted preflight mutations and validated body results. Bypass it using the current input OpenShell retained, then continue with the remaining suffix. Emit partial-coverage telemetry.
    13. An accepted block stops later evaluation and ends still-writable opened stages with MIDDLEWARE_DENIAL.

    HTTP relay behavior

    1. Relay interim 1xx responses unchanged. Start response preflight after parsing the final non-1xx upstream head and before the first sandbox write.
    2. Separate response parsing from downstream commitment.
    3. Commit the final head immediately when the active chain has no whole-body stage. If a whole-body barrier exists, delay commitment until the last barrier completes.
    4. Keep transport framing inside OpenShell. Decode transfer coding, repair Content-Length, choose downstream framing, serialize trailers separately, and reject protected mutations.
    5. Send final HEAD, 204, and 304 responses through preflight. Accept only skip or HEADERS_ONLY, then finish without body or trailers.
    6. Exclude 101 upgrades, successful CONNECT tunnels, and post-upgrade traffic from generic response-body processing.
    7. HEADERS_ONLY may inspect any content encoding. Body modes require absent or identity Content-Encoding.
    8. SSE uses STREAM_BYTES in V1. MIME type does not make an unknown-length response safe to buffer.
    9. Original 206, Content-Range, multipart/byteranges, and Cache-Control: no-transform responses permit only skip or HEADERS_ONLY.
    10. Before a transformation-capable stream commits, remove stale range capability, validators, digests, and signatures.
    11. A whole-body-only chain may retain those fields only when every stage passes through.
    12. Drop stale upstream integrity trailers after transformation before applying trailer mutations.

    Failure and lifecycle

    1. RPC errors, timeouts, malformed results, diagnostic violations, limit failures, and missing results follow on_error.
    2. block_delivery is a successful policy decision and never enters on_error after validation.
    3. A known body larger than a stage limit omits WHOLE_BODY_BYTES for that stage. An unknown-length body that crosses the selected limit fails with whole_body_over_capacity.
    4. Before commitment, fail-closed middleware failure returns the canonical 502 Bad Gateway response with error: response_delivery_failed, bounded L7 context, and a warning that retrying may repeat upstream side effects. It excludes middleware names, free-form reasons, reason codes, upstream status, headers, and content.
    5. For HEAD, send the same 502 headers and canonical content length without a body.
    6. After commitment, fail-closed aborts delivery without injecting error bytes or trailers.
    7. Fail-open preserves accepted preflight mutations and validated output, then bypasses the failed stage using the current input OpenShell retained and continues with the suffix. Fail-open works only for bytes OpenShell still owns.
    8. Use the stage's effective binding timeout for every required result. Limit one unit's complete trip through the chain to the existing 30-second cap.
    9. Do not apply a total lifetime deadline to STREAM_BYTES.
    10. The RPC timeout and per-unit chain cap do not cover accumulation of a complete upstream body. Before the response dispatcher lands, define a dedicated platform-owned total deadline with a configuration source, default, start and end points, non-resetting progress behavior, on_error handling, and tests.
    11. Disconnect, upstream failure, policy reload, cancellation, denial, and middleware failure stop relevant work and end active stages with the matching reason when their streams remain writable.

    Deferred extensions

    • Middleware-author SDK or callback adapter.
    • Explicit supervisor-owned hold or ownership operations for safe cross-unit streaming, plus asynchronous output, finalization, rolling windows, and middleware-owned spooling.
    • Semantic SSE parsing and transformation.
    • Creation of trailer names absent from the upstream trailer event.
    • Response status mutation.
    • Body-derived initial response headers.
    • Content decompression and recompression.
    • HTTP/2 and HTTP/3 relay support.
    • Hook-specific replacements for the existing request and WebSocket APIs. Coordinate request work with feat(supervisor): add streaming HTTP request middleware evaluation #2431.

    Scope

    • proto/supervisor_middleware.proto: operation, service, response events and results, preflight modes, body results, trailers, and shared terminal reasons.
    • crates/openshell-core/src/middleware.rs: response limits and admission accounting.
    • crates/openshell-supervisor-middleware/src/: exact binding validation, ordered response chains, result validation, diagnostics, and remote clients.
    • crates/openshell-supervisor-network/src/l7/: response parsing, commitment, normalization, policy integration, framing repair, and OCSF events.
    • crates/openshell-supervisor-middleware-builtins/ and the content-guard example: one response-capable implementation.

    Implementation steps

    1. Add the operation, phase-specific service, exact manifest validation, response limits, and shared HTTP header validator.
    2. Add ordered response preflight with stage-local modes, header mutation, diagnostics, skip, and block delivery.
    3. Implement WHOLE_BODY_BYTES with one final sequenced input and one validated result.
    4. Implement unit-local lockstep STREAM_BYTES, skip_remaining, per-stage sequences, and chain backpressure.
    5. Add the fixed trailer exchange after final body results.
    6. Split response parsing from commitment and connect transfer decoding, evaluation, blocking, and framing repair.
    7. Add mixed-mode chains, failures, cancellation, policy reload, timeouts, admission, content-encoding behavior, coverage, and OCSF events.
    8. Add a response-capable built-in or example and update RFC 0009, architecture docs, the middleware guide, policy docs, and limit documentation.

    Test plan

    • Contract tests cover service discovery, exact binding selection, message names, event ordering, shared header validation, permitted non-prefixed writes, all result actions, stage-local modes, sequence ordering, required results, final empty units, unit-local streaming, skip remaining, trailer exchange, diagnostics, and terminal reasons.
    • Chain tests cover ordered preflight, head mutations visible to later stages, mixed stage limits, whole-body barriers, streaming, fresh per-stage sequences, later-stage continuation after skip, partial fail-open bypass from OpenShell-owned input, block short-circuiting, capacity, cancellation, and policy reload.
    • Relay tests cover content-length, chunked, close-delimited, interim 1xx, bodyless, empty bodies, identity-coded, unsupported content-coded, partial, original no-transform, stale integrity metadata, trailers, upgrade, tunnel responses, pre-commit and post-commit block delivery, and directional disconnects.
    • End-to-end tests cover replacement, deletion, external middleware failure, no partial release in whole-body mode, overflow, stream-unit bounds, result and chain deadlines, streaming commitment, canonical non-HEAD and HEAD 403 and 502 responses, post-commit abort, SSE byte flushing, and OCSF redaction.
    • Compatibility tests verify existing request and WebSocket implementations still compile and run.
    • Future whole-body deadline tests must cover the chosen default, total rather than idle behavior, trickle input, cancellation, fail-open recovery, and fail-closed response.
    • Later ownership-transfer and semantic SSE work carries its own protocol-extension tests.

    Documentation impact

    • Update RFC 0009 and its protocol-extensions appendix with the response hook, modes, results, trailers, blocking, commitment rules, and shared header authority.
    • Update the supervisor middleware guide with response binding, preflight, body actions, limits, trailers, and failure semantics.
    • Update sandbox architecture and limit docs with response commitment and admission accounting.
    • Update policy docs to state that attaching middleware activates every advertised operation and phase.

    Revision 14: incorporated the API review of PR #3073. Renamed the preflight result, replaced body_end with a final result-bearing body unit, made body modes stage-local, kept streaming unit-local while retaining skip_remaining, completed block_delivery, added the fixed V1 trailer exchange, kept diagnostics on result envelopes, clarified directional disconnect reasons, and deferred cross-unit streaming and the undefined whole-body accumulation deadline until each has an explicit ownership or configuration model.

  4. pimlock commented on Aug 31, 2026

    @pimlock
    CollaboratorAuthor

    HTTP response middleware flow

    The response hook begins after OpenShell sends the request upstream and receives the final non-1xx response head. It controls delivery to the sandbox. It cannot undo the upstream request, so callers must assume that upstream side effects may already have occurred. OpenShell calls the phase-specific HttpResponsePreReturn.Evaluate stream for each selected response stage.

    sequenceDiagram
        participant C as Sandbox client
        participant S as OpenShell supervisor
        participant U as Upstream
        participant M as Current response stage
    
        C->>S: HTTP request
        S->>U: Forward request
        opt Interim 1xx except 101
            U-->>S: Interim response
            S-->>C: Relay unchanged
        end
        U-->>S: Final non-1xx response head
        Note over S,U: HTTP_RESPONSE/PRE_RETURN starts here
    
        loop Each selected stage in policy order
            S->>M: Preflight with current head, context, stage-local modes, and limits
            alt SKIP
                M-->>S: preflight_result.skip plus optional diagnostics
                S->>S: Remove this stage for the response
            else BLOCK_DELIVERY
                M-->>S: preflight_result.block_delivery
                S->>S: Stop later evaluation and deny delivery
            else INSPECT
                M-->>S: preflight_result.inspect with body mode and head mutations
                S->>S: Validate result and apply mutations atomically
            end
        end
    
        alt HEAD, 204, or 304
            Note over S,M: Only SKIP or HEADERS_ONLY is valid
            S-->>C: Commit processed head and finish
        else WHOLE_BODY_BYTES barrier exists
            U-->>S: Complete normalized identity-coded body
            Note over S: Enforce each whole-body stage limit before commitment
            loop Each active body stage in policy order
                S->>M: Final body unit with stage-local sequence and end_of_stream
                M-->>S: Matching body result
            end
            S-->>C: Commit processed head and body
        else STREAM_BYTES
            S-->>C: Commit processed head with streaming framing
            loop Each bounded normalized byte unit
                U-->>S: Response body bytes
                loop Each active body stage in policy order
                    S->>M: Body unit with stage-local sequence
                    M-->>S: Matching body result
                end
                S-->>C: Send validated output
            end
            S->>M: Final result-bearing body unit with end_of_stream
            M-->>S: Matching final body result
        end
    
        opt Normally completed WHOLE_BODY_BYTES or STREAM_BYTES stage
            S->>M: Normalized trailers, including an empty set
            M-->>S: One trailer result
            S-->>C: Validated final trailers
        end
        S-->>M: Best-effort session_end
    
    Loading

    Each stage has its own contiguous sequence space. OpenShell gives a later stage fresh sequence numbers after an earlier stage changes output length or cardinality. V1 waits for exactly one result per input unit. Silence is a timeout, not a buffering request.

    WHOLE_BODY_BYTES sends one final unit for every body-capable response. An empty representation uses present empty data at sequence 1. STREAM_BYTES also ends with one result-bearing unit whose end_of_stream flag is true. OpenShell may send an empty final unit when it learns about EOF after forwarding the last nonempty unit.

    OpenShell derives response-shape eligibility from the original final head, then applies each stage's effective max_payload_bytes. Stages with different limits may receive different permitted_body_modes lists. An eligible unknown-length response may select WHOLE_BODY_BYTES and later fail with whole_body_over_capacity.

    Streaming input units are at most min(64 KiB, max_payload_bytes). Each result accounts for its complete input unit; V1 does not permit cross-unit retention. skip_remaining ends body and trailer events for that stage only; later stages continue.

    Every body-inspecting stage that completes normally receives one trailer event after its final body result, even when no trailers exist. A trailer result may modify or remove names in that event. V1 cannot create a missing trailer name.

    OpenShell decodes HTTP transfer coding before middleware and repairs downstream framing after transformation. V1 body inspection accepts only absent or identity Content-Encoding. HEADERS_ONLY may inspect any content encoding.

    Request and response header mutations use one shared validator and atomic applicator. It permits syntactically valid end-to-end writes without an x-openshell-middleware- prefix and applies direction-specific protected-header rules.

    The original final head controls response-shape eligibility. A 206 response, Content-Range, multipart/byteranges, or original Cache-Control: no-transform permits only SKIP or HEADERS_ONLY. Middleware cannot remove those constraints and then select a body mode.

    block_delivery is a successful policy decision enforced independently of on_error. Before commitment, OpenShell returns its canonical 403 middleware_denied response and closes downstream. After commitment, it aborts delivery without adding an error body, terminating chunk, or error trailer. The upstream request has already run.

    Middleware failures follow on_error. Before commitment, fail-closed returns the canonical 502 response_delivery_failed. After commitment, it aborts the response. Fail-open preserves accepted preflight mutations and validated output, then bypasses the failed stage using the current input OpenShell retained and continues with the remaining suffix. The governing rule is that fail-open works only for bytes OpenShell still owns.

    The per-result middleware timeout and 30-second per-unit chain cap do not define a whole-body accumulation deadline. That deadline needs a dedicated configuration source and default before the response dispatcher lands. STREAM_BYTES has no total response-lifetime deadline.

    The shared terminal enum distinguishes DOWNSTREAM_DISCONNECT, where the sandbox peer disconnected, from UPSTREAM_DISCONNECT, where upstream disconnected after a valid response or upgrade.

  5. added
    state:acceptedA maintainer decided OpenShell should pursue this issue
    and removed
    state:staleInactive item at risk of automatic closure.
    on Sep 1, 2026
  6. moved this from Todo to In progress in OpenShell Roadmapon Sep 1, 2026
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:acceptedA maintainer decided OpenShell should pursue this issuetopic:l7Application-layer policy and inspection work

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions