From 3677a0db57c2b600d8499af4bafe6053f06ae8ea Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Mon, 31 Aug 2026 19:12:42 -0700 Subject: [PATCH 01/24] feat(middleware): define HTTP response pre-return interface Signed-off-by: Piotr Mlocek --- crates/openshell-core/src/middleware.rs | 33 +++- .../src/lib.rs | 48 +++++- .../src/remote.rs | 28 +++- proto/supervisor_middleware.proto | 153 +++++++++++++++++- 4 files changed, 248 insertions(+), 14 deletions(-) diff --git a/crates/openshell-core/src/middleware.rs b/crates/openshell-core/src/middleware.rs index 2b3fb18982..d1d59f2a8c 100644 --- a/crates/openshell-core/src/middleware.rs +++ b/crates/openshell-core/src/middleware.rs @@ -11,11 +11,17 @@ use tokio::sync::mpsc; use tonic::{Request, Response, Status}; use crate::proto::{ - HttpHeader, HttpRequestEvaluation, HttpRequestResult, HttpRequestTarget, MiddlewareManifest, - RequestContext, SupervisorMiddlewarePhase, ValidateConfigRequest, ValidateConfigResponse, - WebSocketSessionEvent, WebSocketSessionEventResult, + HttpHeader, HttpRequestEvaluation, HttpRequestResult, HttpRequestTarget, HttpResponseEvent, + HttpResponseEventResult, MiddlewareManifest, RequestContext, SupervisorMiddlewarePhase, + ValidateConfigRequest, ValidateConfigResponse, WebSocketSessionEvent, + WebSocketSessionEventResult, }; +/// Transport-neutral result stream for one HTTP response middleware stage. +pub type HttpResponseResultStream = Pin< + Box> + Send + 'static>, +>; + /// Transport-neutral response stream for one WebSocket middleware stage. pub type WebSocketResponseStream = Pin< Box< @@ -47,6 +53,15 @@ pub trait SupervisorMiddlewareEndpoint: Send + Sync { &self, requests: mpsc::Receiver, ) -> Result; + + async fn open_http_response_pre_return( + &self, + _requests: mpsc::Receiver, + ) -> Result { + Err(Status::unimplemented( + "middleware does not implement HTTP response pre-return evaluation", + )) + } } /// Borrowed request state exposed to one in-process middleware invocation. @@ -242,6 +257,18 @@ pub trait InProcessMiddleware: Send + Sync { "middleware does not implement WebSocket sessions", )) } + + /// Open one HTTP response pre-return stream. + /// + /// Request-only implementations may keep the default unsupported response. + async fn open_http_response_pre_return( + &self, + _requests: mpsc::Receiver, + ) -> std::result::Result { + Err(Status::unimplemented( + "middleware does not implement HTTP response pre-return evaluation", + )) + } } /// Default timeout for one supervisor middleware RPC. diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index c3a1f6f71a..7bd8885476 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -33,7 +33,8 @@ use tokio::sync::{OnceCell, OwnedSemaphorePermit, Semaphore}; use tonic::{Request, Response as TonicResponse, Status as TonicStatus}; pub use openshell_core::middleware::{ - HttpRequestView, InProcessMiddleware, SupervisorMiddlewareEndpoint, WebSocketResponseStream, + HttpRequestView, HttpResponseResultStream, InProcessMiddleware, SupervisorMiddlewareEndpoint, + WebSocketResponseStream, }; pub type MiddlewareService = dyn SupervisorMiddleware; @@ -180,6 +181,13 @@ impl InProcessMiddleware for EndpointInProcessAdapter { ) -> std::result::Result { self.endpoint.open_websocket_session(requests).await } + + async fn open_http_response_pre_return( + &self, + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + self.endpoint.open_http_response_pre_return(requests).await + } } /// Adapt a transport-neutral endpoint to the in-process registry contract. @@ -823,6 +831,7 @@ fn validate_payload_limit(source: &str, binding: &MiddlewareBinding) -> Result Result Ok(SupportedBinding::HttpPreCredentials), + ( + Some(SupervisorMiddlewareOperation::HttpResponse), + Some(SupervisorMiddlewarePhase::PreReturn), + ) => Ok(SupportedBinding::HttpResponsePreReturn), ( Some(SupervisorMiddlewareOperation::WebsocketMessage), Some(SupervisorMiddlewarePhase::PreCredentials), @@ -1435,6 +1448,20 @@ impl ChainRunner { .entries) } + pub async fn describe_http_response_chain( + &self, + entries: &[ChainEntry], + ) -> Result> { + Ok(self + .describe_chain_for( + entries, + SupervisorMiddlewareOperation::HttpResponse, + SupervisorMiddlewarePhase::PreReturn, + ) + .await? + .entries) + } + async fn describe_chain_for( &self, entries: &[ChainEntry], @@ -3657,6 +3684,25 @@ mod tests { ); } + #[test] + fn manifest_accepts_http_response_pre_return_binding() { + let registration = external_registration(4096); + let manifest = MiddlewareManifest { + name: "example/response".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpResponse as i32, + phase: SupervisorMiddlewarePhase::PreReturn as i32, + max_payload_bytes: 4096, + timeout: "500ms".into(), + }], + expected_audience: String::new(), + }; + + validate_external_manifest(®istration, &manifest, 4096, false) + .expect("HTTP response pre-return binding is supported"); + } + #[test] fn manifest_accepts_forward_websocket_binding_and_reserves_return_phase() { let binding = |phase| MiddlewareBinding { diff --git a/crates/openshell-supervisor-middleware/src/remote.rs b/crates/openshell-supervisor-middleware/src/remote.rs index edc1e8066c..9443038100 100644 --- a/crates/openshell-supervisor-middleware/src/remote.rs +++ b/crates/openshell-supervisor-middleware/src/remote.rs @@ -3,12 +3,14 @@ use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::middleware::{ - HttpRequestView, SupervisorMiddlewareEndpoint, WebSocketResponseStream, + HttpRequestView, HttpResponseResultStream, SupervisorMiddlewareEndpoint, + WebSocketResponseStream, }; +use openshell_core::proto::middleware::v1::http_response_pre_return_client::HttpResponsePreReturnClient; use openshell_core::proto::middleware::v1::supervisor_middleware_client::SupervisorMiddlewareClient; use openshell_core::proto::{ - HttpRequestEvaluation, HttpRequestResult, MiddlewareManifest, ValidateConfigRequest, - ValidateConfigResponse, WebSocketSessionEvent, + HttpRequestEvaluation, HttpRequestResult, HttpResponseEvent, MiddlewareManifest, + ValidateConfigRequest, ValidateConfigResponse, WebSocketSessionEvent, }; use openshell_extension_core::{ BearerTokenInterceptor, BearerTokenSlot, ExtensionChannelConfig, ExtensionServerTrust, @@ -106,6 +108,7 @@ impl GrpcMiddlewareService { #[derive(Clone)] pub struct RemoteMiddlewareService { client: SupervisorMiddlewareClient, + response_client: HttpResponsePreReturnClient, } impl RemoteMiddlewareService { @@ -133,7 +136,10 @@ impl RemoteMiddlewareService { let channel = InterceptedService::new(channel, interceptor); Ok(Self { - client: SupervisorMiddlewareClient::new(channel) + client: SupervisorMiddlewareClient::new(channel.clone()) + .max_decoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES) + .max_encoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES), + response_client: HttpResponsePreReturnClient::new(channel) .max_decoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES) .max_encoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES), }) @@ -179,4 +185,18 @@ impl SupervisorMiddlewareEndpoint for RemoteMiddlewareService { .into_inner(); Ok(Box::pin(responses)) } + + async fn open_http_response_pre_return( + &self, + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + let mut client = self.response_client.clone(); + let responses = client + .evaluate(Request::new(tokio_stream::wrappers::ReceiverStream::new( + receiver, + ))) + .await? + .into_inner(); + Ok(Box::pin(responses)) + } } diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 41b92d8d1a..7e915ca71c 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -33,6 +33,14 @@ service SupervisorMiddleware { returns (stream WebSocketSessionEventResult); } +// HttpResponsePreReturn evaluates one ordered response stream for one selected +// middleware stage after OpenShell receives the final upstream response head +// and before it returns that response to the sandbox. +service HttpResponsePreReturn { + rpc Evaluate(stream HttpResponseEvent) + returns (stream HttpResponseEventResult); +} + // MiddlewareManifest describes one middleware service and the bindings it // exposes. The service is the operator-run gRPC server implementing // SupervisorMiddleware. @@ -57,13 +65,12 @@ message MiddlewareManifest { message MiddlewareBinding { // Supported operation. SupervisorMiddlewareOperation operation = 1; - // Supported evaluation phase. PR 1 supports PRE_CREDENTIALS. PRE_RETURN is - // reserved for the return-path follow-up and is rejected by current - // manifest validation. + // Supported evaluation phase. SupervisorMiddlewarePhase phase = 2; // Maximum logical payload or replacement this binding can process. For - // HTTP_REQUEST this is the request body; for WEBSOCKET_MESSAGE this is one - // complete message. Required for every payload-bearing operation. + // HTTP_REQUEST this is the request body; for HTTP_RESPONSE this is a whole + // body or one streaming input/replacement unit; for WEBSOCKET_MESSAGE this + // is one complete message. Required for every payload-bearing operation. uint64 max_payload_bytes = 3; // Optional binding-specific RPC timeout. Empty uses the operator-configured // service timeout, or the 500ms platform default when that is also omitted. @@ -113,7 +120,7 @@ message HttpRequestEvaluation { string middleware_name = 7; } -// HttpHeader is one request header line. +// HttpHeader is one HTTP header line. message HttpHeader { // Lowercased header name. string name = 1; @@ -121,11 +128,145 @@ message HttpHeader { string value = 2; } +// HttpResponseEvent is one ordered event in a stage-local response stream. +message HttpResponseEvent { + oneof event { + HttpResponsePreflight preflight = 1; + HttpResponseBodyUnit body = 2; + HttpResponseBodyEnd body_end = 3; + HttpResponseTrailers trailers = 4; + HttpResponseSessionEnd session_end = 5; + } +} + +// HttpResponseEventResult acknowledges response preflight, body, or trailers. +// Body end and session end do not produce results in V1. +message HttpResponseEventResult { + oneof result { + HttpResponsePreflightDecision preflight_decision = 1; + HttpResponseBodyResult body_result = 2; + HttpResponseTrailersResult trailers_result = 3; + } +} + +// HttpResponsePreflight exposes the current final response head to one stage. +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; + // Effective minimum of the platform, registration, and binding limits. + uint64 max_payload_bytes = 7; +} + +// HttpResponsePreflightDecision either declines the response or selects an +// inspection mode and mutations. +message HttpResponsePreflightDecision { + oneof decision { + HttpResponsePreflightSkip skip = 1; + HttpResponsePreflightInspect inspect = 2; + } +} + +message HttpResponsePreflightSkip { + // Free-form diagnostic omitted from sandbox responses and OCSF fields. + string reason = 1; + string reason_code = 2; + repeated Finding findings = 3; + map metadata = 4; +} + +message HttpResponsePreflightInspect { + HttpResponseBodyMode body_mode = 1; + repeated HeaderMutation header_mutations = 2; + // Trailer names this stage may add after observing the final body bytes. + repeated string declared_trailer_names = 3; + // Free-form diagnostic omitted from sandbox responses and OCSF fields. + string reason = 4; + repeated Finding findings = 5; + map metadata = 6; +} + +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; +} + +// HttpResponseBodyUnit contains one supervisor-defined logical body unit. Unit +// boundaries have no HTTP transport or application semantic meaning. +message HttpResponseBodyUnit { + // Contiguous and stage-local, starting at 1. + uint64 sequence = 1; + oneof payload { + bytes data = 2; + } +} + +message HttpResponseBodyResult { + // Acknowledges exactly one outstanding body unit. + uint64 sequence = 1; + oneof decision { + HttpResponseBodyPassThrough pass_through = 2; + HttpResponseBodyTransform transform = 3; + } + // Free-form diagnostic omitted from sandbox responses and OCSF fields. + string reason = 4; + repeated Finding findings = 5; + map metadata = 6; +} + +message HttpResponseBodyPassThrough {} + +message HttpResponseBodyTransform { + // Presence is required. Present empty data deletes the input unit. + oneof replacement { + bytes data = 1; + } +} + +message HttpResponseBodyEnd { + // Zero when streaming produced no units; otherwise the final input sequence. + uint64 final_sequence = 1; +} + +message HttpResponseTrailers { + repeated HttpHeader headers = 1; +} + +message HttpResponseTrailersResult { + repeated HeaderMutation trailer_mutations = 1; + // Free-form diagnostic omitted from sandbox responses and OCSF fields. + string reason = 2; + repeated Finding findings = 3; + map metadata = 4; +} + +enum HttpResponseSessionEndReason { + HTTP_RESPONSE_SESSION_END_REASON_UNSPECIFIED = 0; + HTTP_RESPONSE_SESSION_END_REASON_NORMAL = 1; + HTTP_RESPONSE_SESSION_END_REASON_STAGE_SKIPPED = 2; + HTTP_RESPONSE_SESSION_END_REASON_CLIENT_DISCONNECT = 3; + HTTP_RESPONSE_SESSION_END_REASON_UPSTREAM_ERROR = 4; + HTTP_RESPONSE_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; + HTTP_RESPONSE_SESSION_END_REASON_POLICY_RELOAD = 6; + HTTP_RESPONSE_SESSION_END_REASON_CANCELLATION = 7; + HTTP_RESPONSE_SESSION_END_REASON_PROTOCOL_ERROR = 8; +} + +message HttpResponseSessionEnd { + HttpResponseSessionEndReason reason = 1; +} + // Supervisor operation selected for middleware evaluation. enum SupervisorMiddlewareOperation { SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST = 1; SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE = 2; + SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_RESPONSE = 3; } // Ordered phase within a supervisor operation. From 70d3a748c95ae1511b19f03d087819aac4011ec8 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Mon, 31 Aug 2026 23:56:03 -0700 Subject: [PATCH 02/24] docs(middleware): clarify HTTP response interface Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 160 +++++++++++++++++++++++++++--- 1 file changed, 146 insertions(+), 14 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 7e915ca71c..551647a37a 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -36,14 +36,21 @@ service SupervisorMiddleware { // HttpResponsePreReturn evaluates one ordered response stream for one selected // middleware stage after OpenShell receives the final upstream response head // and before it returns that response to the sandbox. +// The operator registration serves this phase-specific service alongside +// SupervisorMiddleware, which remains responsible for discovery and +// configuration validation. service HttpResponsePreReturn { + // Evaluate opens one stage-local stream for one HTTP response. The first + // event is preflight. Body and trailer events follow only when selected by + // the preflight result. OpenShell attempts at most one session_end before + // closing the stream when its transport is still writable. rpc Evaluate(stream HttpResponseEvent) returns (stream HttpResponseEventResult); } // MiddlewareManifest describes one middleware service and the bindings it -// exposes. The service is the operator-run gRPC server implementing -// SupervisorMiddleware. +// exposes. The operator-run gRPC server implements SupervisorMiddleware and +// any phase-specific evaluation service required by its declared bindings. message MiddlewareManifest { // Human-readable middleware service name used only for diagnostics. This is // not required to match an operator-owned registration name. @@ -128,36 +135,66 @@ message HttpHeader { string value = 2; } -// HttpResponseEvent is one ordered event in a stage-local response stream. +// HttpResponseEvent is one ordered event in a stage-local response stream. A +// stream starts with exactly one preflight. An inspecting body stage then +// receives zero or more body units, one body_end, and one trailers event. Skip +// and headers-only stages receive none of those events. OpenShell may finish +// any opened stream with one best-effort session_end. message HttpResponseEvent { oneof event { + // Initial response head and request context for this stage. HttpResponsePreflight preflight = 1; + // One normalized body unit selected by OpenShell. HttpResponseBodyUnit body = 2; + // Notification that OpenShell sent every body unit to this stage. HttpResponseBodyEnd body_end = 3; + // Final normalized response trailers, including an empty trailer set. HttpResponseTrailers trailers = 4; + // Best-effort terminal notification for this stage stream. HttpResponseSessionEnd session_end = 5; } } // HttpResponseEventResult acknowledges response preflight, body, or trailers. -// Body end and session end do not produce results in V1. +// Results must follow event order. Every preflight, body, and trailers event +// requires exactly one matching result. Body end and session end do not +// produce results in V1. message HttpResponseEventResult { oneof result { + // Result for the stream's initial preflight. HttpResponsePreflightDecision preflight_decision = 1; + // Result for the next outstanding body unit. HttpResponseBodyResult body_result = 2; + // Result for the stream's trailers event. HttpResponseTrailersResult trailers_result = 3; } } // HttpResponsePreflight exposes the current final response head to one stage. message HttpResponsePreflight { + // Sandbox and request identity shared with request middleware. The request_id + // correlates the request and response evaluations. The encoded context is + // limited to 4 KiB. RequestContext context = 1; + // Admitted request destination, method, path, and redacted query. The encoded + // target is limited to 32 KiB. HttpRequestTarget target = 2; + // Final non-informational upstream HTTP status code. Interim responses and + // successful protocol upgrades are not evaluated through this service. uint32 status_code = 3; + // Current end-to-end response headers after accepted mutations from earlier + // stages, in wire order. Repeated names remain separate entries. Protected + // routing, credential, framing, and hop-by-hop fields are omitted. At most + // 128 lines and 64 KiB of encoded headers are included. repeated HttpHeader headers = 4; + // Built-in middleware name or operator-owned registration name. string middleware_name = 5; + // Validated service-specific policy configuration. The encoded configuration + // is limited to 64 KiB. google.protobuf.Struct config = 6; - // Effective minimum of the platform, registration, and binding limits. + // Effective minimum of the platform, registration, and binding limits. This + // limits the complete input and replacement in WHOLE_BODY_BYTES, and each + // input unit and replacement in STREAM_BYTES. uint64 max_payload_bytes = 7; } @@ -165,34 +202,73 @@ message HttpResponsePreflight { // inspection mode and mutations. message HttpResponsePreflightDecision { oneof decision { + // Successfully decline this response without invoking on_error. HttpResponsePreflightSkip skip = 1; + // Inspect the response using the selected body mode and mutations. HttpResponsePreflightInspect inspect = 2; } } +// HttpResponsePreflightSkip ends this stage successfully for the current +// response. OpenShell sends no body, body-end, or trailer events to the stage. message HttpResponsePreflightSkip { - // Free-form diagnostic omitted from sandbox responses and OCSF fields. + // Free-form service diagnostic. OpenShell never exposes this to the sandbox + // or security logs. Limited to 4 KiB before discarding. string reason = 1; + // Optional stable machine-readable code for audit output. Codes must start + // with a lowercase ASCII letter and contain only lowercase ASCII letters, + // digits, and underscores, with a maximum length of 64 bytes. string reason_code = 2; + // Audit-safe findings produced during preflight. At most 32 findings of at + // most 4 KiB encoded each are accepted. repeated Finding findings = 3; + // Non-secret service-defined metadata included in diagnostics. At most 64 + // entries and 32 KiB of combined key/value data are accepted. map metadata = 4; } +// HttpResponsePreflightInspect selects this stage's response inspection mode +// and proposes mutations to the current response head. message HttpResponsePreflightInspect { + // Required response body mode. UNSPECIFIED and modes incompatible with the + // response semantics are middleware failures handled according to on_error. + // Only HEADERS_ONLY may inspect HEAD, 204, 304, partial, original + // no-transform, or non-identity content-encoded responses in V1. HttpResponseBodyMode body_mode = 1; + // Ordered response-header mutations applied atomically before the next stage. + // Writes and removals may target permitted visible end-to-end headers. + // Routing, credential, framing, coding, range, and hop-by-hop fields remain + // protected. Integrity fields may be removed but not written. At most 64 + // operations, 32 KiB of validated name/value data, and 64 KiB encoded are + // accepted. repeated HeaderMutation header_mutations = 2; - // Trailer names this stage may add after observing the final body bytes. + // Lowercased trailer names this stage may add after observing the final body + // bytes. OpenShell validates the names before committing the response head. + // Names count against the header-mutation count and encoded-size limits. repeated string declared_trailer_names = 3; - // Free-form diagnostic omitted from sandbox responses and OCSF fields. + // Free-form service diagnostic. OpenShell never exposes this to the sandbox + // or security logs. Limited to 4 KiB before discarding. string reason = 4; + // Audit-safe findings produced during preflight. At most 32 findings of at + // most 4 KiB encoded each are accepted. repeated Finding findings = 5; + // Non-secret service-defined metadata included in diagnostics. At most 64 + // entries and 32 KiB of combined key/value data are accepted. map metadata = 6; } +// HttpResponseBodyMode controls which response-body events one stage receives. enum HttpResponseBodyMode { + // Invalid response value handled according to the policy failure mode. HTTP_RESPONSE_BODY_MODE_UNSPECIFIED = 0; + // Inspect and mutate only the response head. The stage receives no body, + // body-end, or trailer events. HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY = 1; + // Receive the complete normalized response body as exactly one body unit. + // The whole input and its replacement must fit max_payload_bytes. HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES = 2; + // Receive zero or more normalized byte units. Each input and replacement must + // fit max_payload_bytes; the complete response may be larger. HTTP_RESPONSE_BODY_MODE_STREAM_BYTES = 3; } @@ -202,62 +278,118 @@ message HttpResponseBodyUnit { // Contiguous and stage-local, starting at 1. uint64 sequence = 1; oneof payload { + // Normalized response bytes with no HTTP transfer-framing significance. + // WHOLE_BODY_BYTES uses present empty data for an empty body so middleware + // can replace it with nonempty data. STREAM_BYTES units are at most the + // smaller of 64 KiB and max_payload_bytes. bytes data = 2; } } +// HttpResponseBodyResult acknowledges and finalizes exactly one body unit. V1 +// processes units in lockstep and does not send the next unit to a stage until +// the current result has passed validation. V1 does not support denial or +// ownership transfer. message HttpResponseBodyResult { - // Acknowledges exactly one outstanding body unit. + // Must exactly match the sequence of the next outstanding body unit. Zero, + // gaps, duplicates, and regressions are invalid. uint64 sequence = 1; - oneof decision { + // Exactly one delivery action is required. An unset action is a middleware + // failure, not an implicit request for another input unit. Actions are + // explicit rather than inferred from replacement presence so a later version + // can add ownership transfer without changing the RPC cardinality. + oneof action { + // Forward the corresponding input unit without modification. HttpResponseBodyPassThrough pass_through = 2; + // Replace the complete corresponding input unit. HttpResponseBodyTransform transform = 3; } - // Free-form diagnostic omitted from sandbox responses and OCSF fields. + // Free-form service diagnostic. OpenShell never exposes this to the sandbox + // or security logs. Limited to 4 KiB before discarding. string reason = 4; + // Audit-safe findings produced for this body unit. At most 32 findings of at + // most 4 KiB encoded each are accepted. repeated Finding findings = 5; + // Non-secret service-defined metadata included in diagnostics. At most 64 + // entries and 32 KiB of combined key/value data are accepted. map metadata = 6; } +// HttpResponseBodyPassThrough preserves the corresponding input unit exactly. message HttpResponseBodyPassThrough {} +// HttpResponseBodyTransform replaces the complete corresponding input unit. message HttpResponseBodyTransform { - // Presence is required. Present empty data deletes the input unit. + // Exactly one replacement payload is required. Present empty data deletes the + // input unit. The replacement is limited to max_payload_bytes. oneof replacement { + // Normalized replacement response bytes. bytes data = 1; } } +// HttpResponseBodyEnd reports that OpenShell sent every body unit to this stage. +// It requires no result because every preceding unit is already finalized. message HttpResponseBodyEnd { - // Zero when streaming produced no units; otherwise the final input sequence. + // Zero when STREAM_BYTES produced no units; otherwise the final input + // sequence. WHOLE_BODY_BYTES always ends at sequence 1, including for an + // empty representation. uint64 final_sequence = 1; } +// HttpResponseTrailers contains the current normalized trailer set after body +// processing and accepted mutations from earlier stages. OpenShell sends this +// event even when the current trailer set is empty. message HttpResponseTrailers { + // Current end-to-end trailer fields in wire order. Repeated names remain + // separate entries. The response-header count and encoded-size limits apply. repeated HttpHeader headers = 1; } +// HttpResponseTrailersResult proposes mutations to the final response trailers. message HttpResponseTrailersResult { + // Ordered trailer mutations applied atomically before the next stage. A stage + // may add only names declared by its preflight result. Protected response + // fields remain immutable. An empty list preserves the current trailer set. repeated HeaderMutation trailer_mutations = 1; - // Free-form diagnostic omitted from sandbox responses and OCSF fields. + // Free-form service diagnostic. OpenShell never exposes this to the sandbox + // or security logs. Limited to 4 KiB before discarding. string reason = 2; + // Audit-safe findings produced for the final trailers. At most 32 findings of + // at most 4 KiB encoded each are accepted. repeated Finding findings = 3; + // Non-secret service-defined metadata included in diagnostics. At most 64 + // entries and 32 KiB of combined key/value data are accepted. map metadata = 4; } +// Why OpenShell is ending an HTTP response middleware stream. enum HttpResponseSessionEndReason { + // Invalid or unavailable terminal reason. HTTP_RESPONSE_SESSION_END_REASON_UNSPECIFIED = 0; + // The response stage completed normally. HTTP_RESPONSE_SESSION_END_REASON_NORMAL = 1; + // The stage voluntarily declined inspection during preflight. HTTP_RESPONSE_SESSION_END_REASON_STAGE_SKIPPED = 2; + // The sandbox disconnected before response delivery completed. HTTP_RESPONSE_SESSION_END_REASON_CLIENT_DISCONNECT = 3; + // The upstream response failed before delivery completed. HTTP_RESPONSE_SESSION_END_REASON_UPSTREAM_ERROR = 4; + // This or another selected middleware stage failed. HTTP_RESPONSE_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; + // A policy reload replaced the response chain. HTTP_RESPONSE_SESSION_END_REASON_POLICY_RELOAD = 6; + // OpenShell cancelled response evaluation for another lifecycle reason. HTTP_RESPONSE_SESSION_END_REASON_CANCELLATION = 7; + // The middleware exchanged an invalid event or result. HTTP_RESPONSE_SESSION_END_REASON_PROTOCOL_ERROR = 8; } +// HttpResponseSessionEnd is OpenShell's best-effort terminal notification for +// one opened stage stream. A stage receives at most one such notification and +// does not return a result. message HttpResponseSessionEnd { + // Reason OpenShell is ending the stage stream. HttpResponseSessionEndReason reason = 1; } From 8982b5f932e065d16f857dfb469812b722bb09b5 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Tue, 1 Sep 2026 08:39:55 -0700 Subject: [PATCH 03/24] refactor(middleware): align response result actions Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 33 +++++++++++-------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 551647a37a..e82ebed55a 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -199,32 +199,32 @@ message HttpResponsePreflight { } // HttpResponsePreflightDecision either declines the response or selects an -// inspection mode and mutations. +// inspection mode and mutations. Diagnostics apply to either action. message HttpResponsePreflightDecision { - oneof decision { + oneof action { // Successfully decline this response without invoking on_error. HttpResponsePreflightSkip skip = 1; // Inspect the response using the selected body mode and mutations. HttpResponsePreflightInspect inspect = 2; } + // Free-form service diagnostic. OpenShell never exposes this to the sandbox + // or security logs. Limited to 4 KiB before discarding. + string reason = 3; + // Audit-safe findings produced during preflight. At most 32 findings of at + // most 4 KiB encoded each are accepted. + repeated Finding findings = 4; + // Non-secret service-defined metadata included in diagnostics. At most 64 + // entries and 32 KiB of combined key/value data are accepted. + map metadata = 5; } // HttpResponsePreflightSkip ends this stage successfully for the current // response. OpenShell sends no body, body-end, or trailer events to the stage. message HttpResponsePreflightSkip { - // Free-form service diagnostic. OpenShell never exposes this to the sandbox - // or security logs. Limited to 4 KiB before discarding. - string reason = 1; // Optional stable machine-readable code for audit output. Codes must start // with a lowercase ASCII letter and contain only lowercase ASCII letters, // digits, and underscores, with a maximum length of 64 bytes. - string reason_code = 2; - // Audit-safe findings produced during preflight. At most 32 findings of at - // most 4 KiB encoded each are accepted. - repeated Finding findings = 3; - // Non-secret service-defined metadata included in diagnostics. At most 64 - // entries and 32 KiB of combined key/value data are accepted. - map metadata = 4; + string reason_code = 1; } // HttpResponsePreflightInspect selects this stage's response inspection mode @@ -246,15 +246,6 @@ message HttpResponsePreflightInspect { // bytes. OpenShell validates the names before committing the response head. // Names count against the header-mutation count and encoded-size limits. repeated string declared_trailer_names = 3; - // Free-form service diagnostic. OpenShell never exposes this to the sandbox - // or security logs. Limited to 4 KiB before discarding. - string reason = 4; - // Audit-safe findings produced during preflight. At most 32 findings of at - // most 4 KiB encoded each are accepted. - repeated Finding findings = 5; - // Non-secret service-defined metadata included in diagnostics. At most 64 - // entries and 32 KiB of combined key/value data are accepted. - map metadata = 6; } // HttpResponseBodyMode controls which response-body events one stage receives. From 042c5a1c14592bab31eedb670673d3cdb6a19e97 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Tue, 1 Sep 2026 08:53:38 -0700 Subject: [PATCH 04/24] refactor(middleware): expose response reason codes Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 32 +++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index e82ebed55a..6b822179d9 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -210,22 +210,22 @@ message HttpResponsePreflightDecision { // Free-form service diagnostic. OpenShell never exposes this to the sandbox // or security logs. Limited to 4 KiB before discarding. string reason = 3; + // Optional stable machine-readable code for audit and diagnostic output. + // Codes must start with a lowercase ASCII letter and contain only lowercase + // ASCII letters, digits, and underscores, with a maximum length of 64 bytes. + // OpenShell never returns this code to the sandbox. + string reason_code = 4; // Audit-safe findings produced during preflight. At most 32 findings of at // most 4 KiB encoded each are accepted. - repeated Finding findings = 4; + repeated Finding findings = 5; // Non-secret service-defined metadata included in diagnostics. At most 64 // entries and 32 KiB of combined key/value data are accepted. - map metadata = 5; + map metadata = 6; } // HttpResponsePreflightSkip ends this stage successfully for the current // response. OpenShell sends no body, body-end, or trailer events to the stage. -message HttpResponsePreflightSkip { - // Optional stable machine-readable code for audit output. Codes must start - // with a lowercase ASCII letter and contain only lowercase ASCII letters, - // digits, and underscores, with a maximum length of 64 bytes. - string reason_code = 1; -} +message HttpResponsePreflightSkip {} // HttpResponsePreflightInspect selects this stage's response inspection mode // and proposes mutations to the current response head. @@ -298,12 +298,16 @@ message HttpResponseBodyResult { // Free-form service diagnostic. OpenShell never exposes this to the sandbox // or security logs. Limited to 4 KiB before discarding. string reason = 4; + // Optional stable machine-readable code for audit and diagnostic output. + // Codes follow HttpResponsePreflightDecision.reason_code and are never + // returned to the sandbox. + string reason_code = 5; // Audit-safe findings produced for this body unit. At most 32 findings of at // most 4 KiB encoded each are accepted. - repeated Finding findings = 5; + repeated Finding findings = 6; // Non-secret service-defined metadata included in diagnostics. At most 64 // entries and 32 KiB of combined key/value data are accepted. - map metadata = 6; + map metadata = 7; } // HttpResponseBodyPassThrough preserves the corresponding input unit exactly. @@ -346,12 +350,16 @@ message HttpResponseTrailersResult { // Free-form service diagnostic. OpenShell never exposes this to the sandbox // or security logs. Limited to 4 KiB before discarding. string reason = 2; + // Optional stable machine-readable code for audit and diagnostic output. + // Codes follow HttpResponsePreflightDecision.reason_code and are never + // returned to the sandbox. + string reason_code = 3; // Audit-safe findings produced for the final trailers. At most 32 findings of // at most 4 KiB encoded each are accepted. - repeated Finding findings = 3; + repeated Finding findings = 4; // Non-secret service-defined metadata included in diagnostics. At most 64 // entries and 32 KiB of combined key/value data are accepted. - map metadata = 4; + map metadata = 5; } // Why OpenShell is ending an HTTP response middleware stream. From 206df4a72b9621e59c4ce53290aa353febd7890b Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Tue, 1 Sep 2026 09:15:55 -0700 Subject: [PATCH 05/24] refactor(middleware): share session end reasons Signed-off-by: Piotr Mlocek --- .../src/lib.rs | 34 +++--- .../src/websocket.rs | 94 ++++++++++----- .../src/l7/relay.rs | 20 ++-- .../src/l7/websocket.rs | 44 +++---- .../openshell-supervisor-network/src/opa.rs | 2 +- .../openshell-supervisor-network/src/proxy.rs | 14 +-- proto/supervisor_middleware.proto | 109 ++++++++++-------- 7 files changed, 179 insertions(+), 138 deletions(-) diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index 7bd8885476..0aac3384d3 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -4759,7 +4759,7 @@ mod tests { close_on_first_message: bool, messages: Arc, session_ends: Option< - tokio::sync::mpsc::UnboundedSender, + tokio::sync::mpsc::UnboundedSender, >, } @@ -4850,7 +4850,7 @@ mod tests { Some(web_socket_session_event::Event::SessionEnd(end)) => { if let Some(session_ends) = &session_ends && let Ok(reason) = - openshell_core::proto::WebSocketSessionEndReason::try_from( + openshell_core::proto::MiddlewareSessionEndReason::try_from( end.reason, ) { @@ -5135,14 +5135,14 @@ mod tests { assert!(!text.invocations[0].failed); session - .end(openshell_core::proto::WebSocketSessionEndReason::NormalClose) + .end(openshell_core::proto::MiddlewareSessionEndReason::Normal) .await; } } #[tokio::test] async fn explicit_websocket_preflight_denial_is_authoritative_for_both_error_modes() { - use openshell_core::proto::WebSocketSessionEndReason; + use openshell_core::proto::MiddlewareSessionEndReason; for on_error in [OnError::FailOpen, OnError::FailClosed] { let (session_ends_tx, mut session_ends_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -5178,7 +5178,7 @@ mod tests { assert!(!outcome.allowed); assert_eq!( outcome.terminal_reason, - Some(WebSocketSessionEndReason::MiddlewareDenial) + Some(MiddlewareSessionEndReason::MiddlewareDenial) ); assert_eq!( outcome.reason, @@ -5216,7 +5216,7 @@ mod tests { assert!(!outcome.invocations[0].failed); assert_eq!( session_ends_rx.recv().await, - Some(WebSocketSessionEndReason::MiddlewareDenial) + Some(MiddlewareSessionEndReason::MiddlewareDenial) ); assert!( session_ends_rx.try_recv().is_err(), @@ -5227,7 +5227,7 @@ mod tests { #[tokio::test] async fn mixed_websocket_preflight_denial_ends_every_opened_stage() { - use openshell_core::proto::WebSocketSessionEndReason; + use openshell_core::proto::MiddlewareSessionEndReason; let (first_end_tx, mut first_end_rx) = tokio::sync::mpsc::unbounded_channel(); let (denier_end_tx, mut denier_end_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -5269,7 +5269,7 @@ mod tests { assert!(!outcome.allowed); assert_eq!( outcome.terminal_reason, - Some(WebSocketSessionEndReason::MiddlewareDenial) + Some(MiddlewareSessionEndReason::MiddlewareDenial) ); assert_eq!( outcome @@ -5286,7 +5286,7 @@ mod tests { for receiver in [&mut first_end_rx, &mut denier_end_rx, &mut last_end_rx] { assert_eq!( receiver.recv().await, - Some(WebSocketSessionEndReason::MiddlewareDenial) + Some(MiddlewareSessionEndReason::MiddlewareDenial) ); assert!( receiver.try_recv().is_err(), @@ -5363,7 +5363,7 @@ mod tests { "middleware_failed: request_message_over_capacity" ); session - .end(openshell_core::proto::WebSocketSessionEndReason::NormalClose) + .end(openshell_core::proto::MiddlewareSessionEndReason::Normal) .await; } @@ -5420,7 +5420,7 @@ mod tests { assert!(!redacted.invocations[0].stage_disabled); session - .end(openshell_core::proto::WebSocketSessionEndReason::NormalClose) + .end(openshell_core::proto::MiddlewareSessionEndReason::Normal) .await; } @@ -5466,7 +5466,7 @@ mod tests { ); assert!(outcome.invocations[0].transformed); session - .end(openshell_core::proto::WebSocketSessionEndReason::NormalClose) + .end(openshell_core::proto::MiddlewareSessionEndReason::Normal) .await; } @@ -5552,7 +5552,7 @@ mod tests { assert!(target.query.is_empty()); assert_eq!(observed.requested_subprotocols, ["realtime"]); session - .end(openshell_core::proto::WebSocketSessionEndReason::NormalClose) + .end(openshell_core::proto::MiddlewareSessionEndReason::Normal) .await; let _ = shutdown_tx.send(()); server_task @@ -5663,7 +5663,7 @@ mod tests { drop(work); session - .end(openshell_core::proto::WebSocketSessionEndReason::NormalClose) + .end(openshell_core::proto::MiddlewareSessionEndReason::Normal) .await; let _ = shutdown_tx.send(()); server_task @@ -5925,7 +5925,7 @@ mod tests { tokio::time::timeout(Duration::from_secs(1), session_ends_rx.recv()) .await .expect("skipped stage must receive session_end"), - Some(openshell_core::proto::WebSocketSessionEndReason::StageSkipped) + Some(openshell_core::proto::MiddlewareSessionEndReason::StageSkipped) ); assert!( session_ends_rx.try_recv().is_err(), @@ -5971,7 +5971,7 @@ mod tests { sessions .pop() .expect("retained session") - .end(openshell_core::proto::WebSocketSessionEndReason::NormalClose) + .end(openshell_core::proto::MiddlewareSessionEndReason::Normal) .await; assert_eq!(runner.registry.session_admission.available_permits(), 1); @@ -6013,7 +6013,7 @@ mod tests { sessions .pop() .expect("retained old-generation session") - .end(openshell_core::proto::WebSocketSessionEndReason::PolicyReload) + .end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) .await; let admitted = replacement .preflight_websocket(&chain, websocket_preflight_input("new-generation-admitted")) diff --git a/crates/openshell-supervisor-middleware/src/websocket.rs b/crates/openshell-supervisor-middleware/src/websocket.rs index e61460946a..900a821e58 100644 --- a/crates/openshell-supervisor-middleware/src/websocket.rs +++ b/crates/openshell-supervisor-middleware/src/websocket.rs @@ -12,11 +12,12 @@ use tokio::sync::mpsc; use tokio::time::Instant; use openshell_core::proto::{ - Decision, HttpRequestTarget, RequestContext, SupervisorMiddlewarePhase, WebSocketMessage, + Decision, HttpRequestTarget, MiddlewareSessionEnd, MiddlewareSessionEndReason, + MiddlewareSessionProtocolError, RequestContext, SupervisorMiddlewarePhase, WebSocketMessage, WebSocketMessageResult, WebSocketPreflight, WebSocketPreflightAction, - WebSocketPreflightDecision, WebSocketSessionEnd, WebSocketSessionEndReason, - WebSocketSessionEvent, WebSocketSessionStart, web_socket_message, web_socket_message_result, - web_socket_session_event, web_socket_session_event_result, + WebSocketPreflightDecision, WebSocketProtocolError, WebSocketSessionEvent, + WebSocketSessionStart, middleware_session_protocol_error, web_socket_message, + web_socket_message_result, web_socket_session_event, web_socket_session_event_result, }; use super::{ @@ -107,7 +108,7 @@ pub struct WebSocketPreflightResult { pub allowed: bool, /// Typed terminal reason when preflight denied the upgrade. `None` means /// the request may continue, including voluntary skip and fail-open. - pub terminal_reason: Option, + pub terminal_reason: Option, pub reason: String, pub denial: Option, pub session: Option, @@ -123,7 +124,7 @@ pub struct WebSocketPreflightResult { pub struct WebSocketSessionStartOutcome { pub allowed: bool, /// Typed terminal reason when session start cannot continue. - pub terminal_reason: Option, + pub terminal_reason: Option, pub reason: String, pub invocations: Vec, } @@ -147,11 +148,11 @@ struct WebSocketStageTransport { } impl WebSocketStageTransport { - async fn end(self, reason: WebSocketSessionEndReason) { + async fn end(self, reason: MiddlewareSessionEndReason) { let _ = tokio::time::timeout(SESSION_END_TIMEOUT, self.end_inner(reason)).await; } - async fn end_inner(self, reason: WebSocketSessionEndReason) { + async fn end_inner(self, reason: MiddlewareSessionEndReason) { if self.sender.send(session_end_request(reason)).await.is_err() { return; } @@ -170,7 +171,7 @@ impl WebSocketStageTransport { while responses.next().await.is_some() {} } - fn end_now(self, reason: WebSocketSessionEndReason) { + fn end_now(self, reason: MiddlewareSessionEndReason) { if self.sender.try_send(session_end_request(reason)).is_err() { return; } @@ -193,10 +194,11 @@ impl WebSocketStage { } async fn disable(&mut self) { - self.end(WebSocketSessionEndReason::MiddlewareFailure).await; + self.end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; } - async fn end(&mut self, reason: WebSocketSessionEndReason) { + async fn end(&mut self, reason: MiddlewareSessionEndReason) { if let Some(transport) = self.transport.take() { transport.end(reason).await; } @@ -328,10 +330,10 @@ impl ChainRunner { } if let Some(denial) = denial { - end_stages(&mut stages, WebSocketSessionEndReason::MiddlewareDenial).await; + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareDenial).await; return Ok(WebSocketPreflightResult { allowed: false, - terminal_reason: Some(WebSocketSessionEndReason::MiddlewareDenial), + terminal_reason: Some(MiddlewareSessionEndReason::MiddlewareDenial), reason: middleware_denial_reason( &denial.config_name, denial.reason_code.as_deref(), @@ -348,10 +350,10 @@ impl ChainRunner { } if let Some(reason) = fail_closed_reason { - end_stages(&mut stages, WebSocketSessionEndReason::MiddlewareFailure).await; + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; return Ok(WebSocketPreflightResult { allowed: false, - terminal_reason: Some(WebSocketSessionEndReason::MiddlewareFailure), + terminal_reason: Some(MiddlewareSessionEndReason::MiddlewareFailure), reason, denial: None, session: None, @@ -434,7 +436,7 @@ fn session_capacity_exhausted( .collect(); WebSocketPreflightResult { allowed: !fail_closed, - terminal_reason: fail_closed.then_some(WebSocketSessionEndReason::MiddlewareFailure), + terminal_reason: fail_closed.then_some(MiddlewareSessionEndReason::MiddlewareFailure), reason: if fail_closed { format!("middleware_failed: {reason}") } else { @@ -477,7 +479,7 @@ impl WebSocketSession { if selected_subprotocol.len() > MAX_SELECTED_SUBPROTOCOL_BYTES { return WebSocketSessionStartOutcome { allowed: false, - terminal_reason: Some(WebSocketSessionEndReason::MiddlewareFailure), + terminal_reason: Some(MiddlewareSessionEndReason::MiddlewareFailure), reason: "middleware_failed: selected_subprotocol_over_capacity".to_string(), invocations: Vec::new(), }; @@ -513,7 +515,7 @@ impl WebSocketSession { allowed: fail_closed.is_none(), terminal_reason: fail_closed .as_ref() - .map(|_| WebSocketSessionEndReason::MiddlewareFailure), + .map(|_| MiddlewareSessionEndReason::MiddlewareFailure), reason: fail_closed.unwrap_or_default(), invocations, } @@ -837,7 +839,7 @@ impl WebSocketSession { } } - pub async fn end(mut self, reason: WebSocketSessionEndReason) { + pub async fn end(mut self, reason: MiddlewareSessionEndReason) { end_stages(&mut self.stages, reason).await; self.reconcile_lifecycle(); } @@ -845,7 +847,7 @@ impl WebSocketSession { impl Drop for WebSocketSession { fn drop(&mut self) { - end_stages_now(&mut self.stages, WebSocketSessionEndReason::Cancellation); + end_stages_now(&mut self.stages, MiddlewareSessionEndReason::Cancellation); self.reconcile_lifecycle(); } } @@ -999,7 +1001,7 @@ async fn open_stage(entry: DescribedChainEntry, input: WebSocketPreflightInput) }; let Some(response) = response else { WebSocketStageTransport { sender, responses } - .end(WebSocketSessionEndReason::MiddlewareFailure) + .end(MiddlewareSessionEndReason::MiddlewareFailure) .await; return OpenStage::Failed(entry, "missing_preflight_decision".into()); }; @@ -1007,7 +1009,7 @@ async fn open_stage(entry: DescribedChainEntry, input: WebSocketPreflightInput) response.result else { WebSocketStageTransport { sender, responses } - .end(WebSocketSessionEndReason::MiddlewareFailure) + .end(MiddlewareSessionEndReason::MiddlewareFailure) .await; return OpenStage::Failed(entry, "invalid_preflight_decision".into()); }; @@ -1015,7 +1017,7 @@ async fn open_stage(entry: DescribedChainEntry, input: WebSocketPreflightInput) Ok(decision) => decision, Err(reason) => { WebSocketStageTransport { sender, responses } - .end(WebSocketSessionEndReason::MiddlewareFailure) + .end(MiddlewareSessionEndReason::MiddlewareFailure) .await; return OpenStage::Failed(entry, reason.into()); } @@ -1049,7 +1051,7 @@ async fn open_stage(entry: DescribedChainEntry, input: WebSocketPreflightInput) let outcome = preflight_stage_outcome(&entry, WebSocketInvocationOutcome::Skip, decision); WebSocketStageTransport { sender, responses } - .end(WebSocketSessionEndReason::StageSkipped) + .end(MiddlewareSessionEndReason::StageSkipped) .await; OpenStage::Skip(outcome) } @@ -1334,13 +1336,13 @@ fn failure_invocation( } } -async fn end_stages(stages: &mut [WebSocketStage], reason: WebSocketSessionEndReason) { +async fn end_stages(stages: &mut [WebSocketStage], reason: MiddlewareSessionEndReason) { for stage in stages { stage.end(reason).await; } } -fn end_stages_now(stages: &mut [WebSocketStage], reason: WebSocketSessionEndReason) { +fn end_stages_now(stages: &mut [WebSocketStage], reason: MiddlewareSessionEndReason) { for stage in stages { if let Some(transport) = stage.transport.take() { transport.end_now(reason); @@ -1348,11 +1350,19 @@ fn end_stages_now(stages: &mut [WebSocketStage], reason: WebSocketSessionEndReas } } -fn session_end_request(reason: WebSocketSessionEndReason) -> WebSocketSessionEvent { +fn session_end_request(reason: MiddlewareSessionEndReason) -> WebSocketSessionEvent { + let protocol_error = (reason == MiddlewareSessionEndReason::ProtocolError).then_some({ + MiddlewareSessionProtocolError { + domain: Some(middleware_session_protocol_error::Domain::WebSocket( + WebSocketProtocolError {}, + )), + } + }); WebSocketSessionEvent { event: Some(web_socket_session_event::Event::SessionEnd( - WebSocketSessionEnd { + MiddlewareSessionEnd { reason: reason as i32, + protocol_error, }, )), } @@ -1362,6 +1372,30 @@ fn session_end_request(reason: WebSocketSessionEndReason) -> WebSocketSessionEve mod tests { use super::*; + #[test] + fn session_end_refines_only_protocol_errors() { + let protocol_event = session_end_request(MiddlewareSessionEndReason::ProtocolError); + let Some(web_socket_session_event::Event::SessionEnd(protocol_end)) = protocol_event.event + else { + panic!("expected session end"); + }; + assert_eq!( + MiddlewareSessionEndReason::try_from(protocol_end.reason), + Ok(MiddlewareSessionEndReason::ProtocolError) + ); + assert!(matches!( + protocol_end.protocol_error.and_then(|detail| detail.domain), + Some(middleware_session_protocol_error::Domain::WebSocket(_)) + )); + + let normal_event = session_end_request(MiddlewareSessionEndReason::Normal); + let Some(web_socket_session_event::Event::SessionEnd(normal_end)) = normal_event.event + else { + panic!("expected session end"); + }; + assert!(normal_end.protocol_error.is_none()); + } + #[test] fn protobuf_rejects_invalid_utf8_text_payload() { let encoded_text_with_invalid_utf8 = [0x12, 0x01, 0xff]; @@ -1436,8 +1470,8 @@ mod tests { panic!("disabled stage must receive session end"); }; assert_eq!( - WebSocketSessionEndReason::try_from(end.reason), - Ok(WebSocketSessionEndReason::MiddlewareFailure) + MiddlewareSessionEndReason::try_from(end.reason), + Ok(MiddlewareSessionEndReason::MiddlewareFailure) ); assert!( requests.try_recv().is_err(), diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 2697fedb3c..9e4949b191 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -855,7 +855,7 @@ where Ok(None) => { if let Some(session) = middleware_session.take() { session - .end(openshell_core::proto::WebSocketSessionEndReason::Cancellation) + .end(openshell_core::proto::MiddlewareSessionEndReason::Cancellation) .await; } return Ok(()); @@ -875,14 +875,14 @@ where RelayOutcome::Reusable => { if let Some(session) = middleware_session.take() { session - .end(openshell_core::proto::WebSocketSessionEndReason::UpstreamRejected) + .end(openshell_core::proto::MiddlewareSessionEndReason::UpstreamFailure) .await; } } RelayOutcome::Consumed => { if let Some(session) = middleware_session.take() { session - .end(openshell_core::proto::WebSocketSessionEndReason::UpstreamRejected) + .end(openshell_core::proto::MiddlewareSessionEndReason::UpstreamFailure) .await; } return Ok(()); @@ -1217,7 +1217,7 @@ where emit_policy_reload(guard, host, port, &options.policy_name); if let Some(session) = options.middleware_session.take() { session - .end(openshell_core::proto::WebSocketSessionEndReason::PolicyReload) + .end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) .await; } send_websocket_close(client, upstream, 1012).await; @@ -1594,7 +1594,7 @@ where Ok(None) => { if let Some(session) = middleware_session.take() { session - .end(openshell_core::proto::WebSocketSessionEndReason::Cancellation) + .end(openshell_core::proto::MiddlewareSessionEndReason::Cancellation) .await; } return Ok(()); @@ -1614,14 +1614,14 @@ where RelayOutcome::Reusable => { if let Some(session) = middleware_session.take() { session - .end(openshell_core::proto::WebSocketSessionEndReason::UpstreamRejected) + .end(openshell_core::proto::MiddlewareSessionEndReason::UpstreamFailure) .await; } } RelayOutcome::Consumed => { if let Some(session) = middleware_session.take() { session - .end(openshell_core::proto::WebSocketSessionEndReason::UpstreamRejected) + .end(openshell_core::proto::MiddlewareSessionEndReason::UpstreamFailure) .await; } debug!( @@ -1720,7 +1720,7 @@ pub(crate) async fn finalize_websocket_pre_upgrade( emit_policy_reload(guard, host, port, policy_name); if let Some(session) = session.take() { session - .end(openshell_core::proto::WebSocketSessionEndReason::PolicyReload) + .end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) .await; } Err(error) @@ -1731,9 +1731,9 @@ pub(crate) async fn finalize_websocket_pre_upgrade( Err(error) => { let reason = if guard.is_stale() { emit_policy_reload(guard, host, port, policy_name); - openshell_core::proto::WebSocketSessionEndReason::PolicyReload + openshell_core::proto::MiddlewareSessionEndReason::PolicyReload } else { - openshell_core::proto::WebSocketSessionEndReason::UpstreamRejected + openshell_core::proto::MiddlewareSessionEndReason::UpstreamFailure }; if let Some(session) = session.take() { session.end(reason).await; diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index 6cd8aa4818..a039a8909c 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -121,21 +121,21 @@ impl WebSocketTerminationCause { } } - fn session_end_reason(self) -> openshell_core::proto::WebSocketSessionEndReason { + fn session_end_reason(self) -> openshell_core::proto::MiddlewareSessionEndReason { match self { Self::PeerDisconnect => { - openshell_core::proto::WebSocketSessionEndReason::PeerDisconnect + openshell_core::proto::MiddlewareSessionEndReason::PeerDisconnect } - Self::PolicyReload => openshell_core::proto::WebSocketSessionEndReason::PolicyReload, + Self::PolicyReload => openshell_core::proto::MiddlewareSessionEndReason::PolicyReload, Self::MiddlewareDenial => { - openshell_core::proto::WebSocketSessionEndReason::MiddlewareDenial + openshell_core::proto::MiddlewareSessionEndReason::MiddlewareDenial } - Self::PolicyDenial => openshell_core::proto::WebSocketSessionEndReason::PolicyDenial, + Self::PolicyDenial => openshell_core::proto::MiddlewareSessionEndReason::PolicyDenial, Self::CapacityExhausted | Self::MiddlewareFailure => { - openshell_core::proto::WebSocketSessionEndReason::MiddlewareFailure + openshell_core::proto::MiddlewareSessionEndReason::MiddlewareFailure } Self::InvalidUtf8 | Self::ProtocolError | Self::MessageTooBig => { - openshell_core::proto::WebSocketSessionEndReason::ProtocolError + openshell_core::proto::MiddlewareSessionEndReason::ProtocolError } } } @@ -539,7 +539,7 @@ where ) })?; Ok::<_, WebSocketTermination>( - openshell_core::proto::WebSocketSessionEndReason::PeerDisconnect, + openshell_core::proto::MiddlewareSessionEndReason::PeerDisconnect, ) }; @@ -619,7 +619,7 @@ async fn relay_client_to_server( host: &str, port: u16, options: &mut RelayOptions<'_>, -) -> WebSocketRelayResult +) -> WebSocketRelayResult where R: AsyncRead + Unpin, W: AsyncWrite + Unpin, @@ -637,9 +637,9 @@ where else { let _ = writer.shutdown().await; return Ok(if close_seen { - openshell_core::proto::WebSocketSessionEndReason::NormalClose + openshell_core::proto::MiddlewareSessionEndReason::Normal } else { - openshell_core::proto::WebSocketSessionEndReason::PeerDisconnect + openshell_core::proto::MiddlewareSessionEndReason::PeerDisconnect }); }; @@ -2216,7 +2216,7 @@ network_policies: #[test] fn termination_causes_map_to_protocol_close_codes_and_session_reasons() { - use openshell_core::proto::WebSocketSessionEndReason as EndReason; + use openshell_core::proto::MiddlewareSessionEndReason as EndReason; assert_eq!( WebSocketTerminationCause::InvalidUtf8.close_code(), @@ -3486,7 +3486,7 @@ network_policies: enum ObservedWebSocketRequest { SessionStart, Message { sequence: u64, payload: String }, - SessionEnd(openshell_core::proto::WebSocketSessionEndReason), + SessionEnd(openshell_core::proto::MiddlewareSessionEndReason), } #[derive(Clone, Default)] @@ -3648,7 +3648,7 @@ network_policies: Some(web_socket_session_event::Event::SessionEnd(end)) => { if let Some(observed) = &observed && let Ok(reason) = - openshell_core::proto::WebSocketSessionEndReason::try_from( + openshell_core::proto::MiddlewareSessionEndReason::try_from( end.reason, ) { @@ -3898,7 +3898,7 @@ network_policies: assert!(matches!( observed.recv().await, Some(ObservedWebSocketRequest::SessionEnd( - openshell_core::proto::WebSocketSessionEndReason::ProtocolError, + openshell_core::proto::MiddlewareSessionEndReason::ProtocolError, )) )); assert!( @@ -4018,7 +4018,7 @@ network_policies: assert!(matches!( observed.recv().await, Some(ObservedWebSocketRequest::SessionEnd( - openshell_core::proto::WebSocketSessionEndReason::PeerDisconnect, + openshell_core::proto::MiddlewareSessionEndReason::PeerDisconnect, )) )); @@ -4056,7 +4056,7 @@ network_policies: assert!(matches!( observed.recv().await, Some(ObservedWebSocketRequest::SessionEnd( - openshell_core::proto::WebSocketSessionEndReason::MiddlewareFailure, + openshell_core::proto::MiddlewareSessionEndReason::MiddlewareFailure, )) )); assert!( @@ -4258,7 +4258,7 @@ network_policies: assert!(error.to_string().contains("policy generation is stale")); match observed.recv().await { Some(ObservedWebSocketRequest::SessionEnd( - openshell_core::proto::WebSocketSessionEndReason::PolicyReload, + openshell_core::proto::MiddlewareSessionEndReason::PolicyReload, )) => {} Some(ObservedWebSocketRequest::Message { payload, .. }) => { panic!("stale message leaked {} bytes to middleware", payload.len()); @@ -4383,7 +4383,7 @@ network_policies: } assert_eq!( end_reason, - Some(openshell_core::proto::WebSocketSessionEndReason::PolicyReload) + Some(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) ); let _ = shutdown_tx.send(()); @@ -4423,7 +4423,7 @@ network_policies: assert!(matches!( observed.recv().await, Some(ObservedWebSocketRequest::SessionEnd( - openshell_core::proto::WebSocketSessionEndReason::PolicyReload + openshell_core::proto::MiddlewareSessionEndReason::PolicyReload )) )); @@ -4527,7 +4527,7 @@ network_policies: assert!(matches!( observed.recv().await, Some(ObservedWebSocketRequest::SessionEnd( - openshell_core::proto::WebSocketSessionEndReason::PolicyReload + openshell_core::proto::MiddlewareSessionEndReason::PolicyReload )) )); @@ -4637,7 +4637,7 @@ network_policies: ); match observed.recv().await { Some(ObservedWebSocketRequest::SessionEnd( - openshell_core::proto::WebSocketSessionEndReason::PolicyDenial, + openshell_core::proto::MiddlewareSessionEndReason::PolicyDenial, )) => {} Some(ObservedWebSocketRequest::Message { payload, .. }) => { panic!( diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 63aa2c2c70..60fb96cb0a 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -7999,7 +7999,7 @@ network_policies: old_sessions .pop() .expect("old-generation session") - .end(openshell_core::proto::WebSocketSessionEndReason::PolicyReload) + .end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) .await; let admitted = current_runner .preflight_websocket( diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index dc2736a4ea..177d640fd8 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -5809,7 +5809,7 @@ async fn handle_forward_proxy( ); if let Some(session) = middleware_session.take() { session - .end(openshell_core::proto::WebSocketSessionEndReason::Cancellation) + .end(openshell_core::proto::MiddlewareSessionEndReason::Cancellation) .await; } respond( @@ -5869,7 +5869,7 @@ async fn handle_forward_proxy( ); if let Some(session) = middleware_session.take() { session - .end(openshell_core::proto::WebSocketSessionEndReason::Cancellation) + .end(openshell_core::proto::MiddlewareSessionEndReason::Cancellation) .await; } if e.is_endpoint_mismatch() { @@ -5912,7 +5912,7 @@ async fn handle_forward_proxy( emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); if let Some(session) = middleware_session.take() { session - .end(openshell_core::proto::WebSocketSessionEndReason::PolicyReload) + .end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) .await; } respond( @@ -5957,7 +5957,7 @@ async fn handle_forward_proxy( ocsf_emit!(event); if let Some(session) = middleware_session.take() { session - .end(openshell_core::proto::WebSocketSessionEndReason::UpstreamRejected) + .end(openshell_core::proto::MiddlewareSessionEndReason::UpstreamFailure) .await; } respond( @@ -5986,7 +5986,7 @@ async fn handle_forward_proxy( emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); if let Some(session) = middleware_session.take() { session - .end(openshell_core::proto::WebSocketSessionEndReason::PolicyReload) + .end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) .await; } respond( @@ -6039,7 +6039,7 @@ async fn handle_forward_proxy( if let Some(error) = report.downcast_ref::() { if let Some(session) = middleware_session.take() { session - .end(openshell_core::proto::WebSocketSessionEndReason::Cancellation) + .end(openshell_core::proto::MiddlewareSessionEndReason::Cancellation) .await; } crate::l7::relay::reject_credential_resolution(client, &l7_ctx, error).await?; @@ -6081,7 +6081,7 @@ async fn handle_forward_proxy( | crate::l7::provider::RelayOutcome::Consumed => { if let Some(session) = middleware_session.take() { session - .end(openshell_core::proto::WebSocketSessionEndReason::UpstreamRejected) + .end(openshell_core::proto::MiddlewareSessionEndReason::UpstreamFailure) .await; } } diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 6b822179d9..1032372acc 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -151,7 +151,7 @@ message HttpResponseEvent { // Final normalized response trailers, including an empty trailer set. HttpResponseTrailers trailers = 4; // Best-effort terminal notification for this stage stream. - HttpResponseSessionEnd session_end = 5; + MiddlewareSessionEnd session_end = 5; } } @@ -362,35 +362,66 @@ message HttpResponseTrailersResult { map metadata = 5; } -// Why OpenShell is ending an HTTP response middleware stream. -enum HttpResponseSessionEndReason { +// Why OpenShell is ending an opened middleware stage stream. This enum is a +// stable lifecycle classification shared by every streaming middleware +// protocol. Protocol-specific information belongs in MiddlewareSessionEnd. +enum MiddlewareSessionEndReason { // Invalid or unavailable terminal reason. - HTTP_RESPONSE_SESSION_END_REASON_UNSPECIFIED = 0; - // The response stage completed normally. - HTTP_RESPONSE_SESSION_END_REASON_NORMAL = 1; - // The stage voluntarily declined inspection during preflight. - HTTP_RESPONSE_SESSION_END_REASON_STAGE_SKIPPED = 2; - // The sandbox disconnected before response delivery completed. - HTTP_RESPONSE_SESSION_END_REASON_CLIENT_DISCONNECT = 3; - // The upstream response failed before delivery completed. - HTTP_RESPONSE_SESSION_END_REASON_UPSTREAM_ERROR = 4; + MIDDLEWARE_SESSION_END_REASON_UNSPECIFIED = 0; + // The evaluated interaction completed normally. + MIDDLEWARE_SESSION_END_REASON_NORMAL = 1; + // A downstream or upstream peer disconnected before processing completed. + MIDDLEWARE_SESSION_END_REASON_PEER_DISCONNECT = 2; + // A policy reload replaced the active middleware chain. + MIDDLEWARE_SESSION_END_REASON_POLICY_RELOAD = 3; + // A middleware stage authoritatively denied the operation. + MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_DENIAL = 4; // This or another selected middleware stage failed. - HTTP_RESPONSE_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; - // A policy reload replaced the response chain. - HTTP_RESPONSE_SESSION_END_REASON_POLICY_RELOAD = 6; - // OpenShell cancelled response evaluation for another lifecycle reason. - HTTP_RESPONSE_SESSION_END_REASON_CANCELLATION = 7; - // The middleware exchanged an invalid event or result. - HTTP_RESPONSE_SESSION_END_REASON_PROTOCOL_ERROR = 8; + MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; + // A proxied or middleware protocol contract was violated. Producers set + // protocol_error to identify the violated contract. + MIDDLEWARE_SESSION_END_REASON_PROTOCOL_ERROR = 6; + // OpenShell cancelled middleware evaluation for another lifecycle reason. + MIDDLEWARE_SESSION_END_REASON_CANCELLATION = 7; + // The upstream operation failed or was rejected before completion. + MIDDLEWARE_SESSION_END_REASON_UPSTREAM_FAILURE = 8; + // Network policy denied the operation. + MIDDLEWARE_SESSION_END_REASON_POLICY_DENIAL = 9; + // The middleware stage voluntarily declined inspection during preflight. + // This is a successful stage-local outcome, not a cancellation or denial. + MIDDLEWARE_SESSION_END_REASON_STAGE_SKIPPED = 10; +} + +// MiddlewareSessionEnd is OpenShell's best-effort terminal notification for +// one opened stage stream. A stage receives at most one and does not return a +// result. The reason remains meaningful when protocol_error is absent or its +// domain is unknown to an older consumer. +message MiddlewareSessionEnd { + // Stable lifecycle classification. Producers never send UNSPECIFIED. + MiddlewareSessionEndReason reason = 1; + // Structured detail set exactly when reason is PROTOCOL_ERROR. Consumers + // treat absent or unrecognized detail as a generic protocol error. + MiddlewareSessionProtocolError protocol_error = 2; +} + +// MiddlewareSessionProtocolError identifies the contract violated by a +// protocol error. OpenShell adds typed domains as it supports more protocols. +message MiddlewareSessionProtocolError { + oneof domain { + // The proxied WebSocket traffic violated the WebSocket protocol. + WebSocketProtocolError web_socket = 1; + // The middleware event/result exchange violated the OpenShell protocol. + MiddlewareExchangeProtocolError middleware_exchange = 2; + } } -// HttpResponseSessionEnd is OpenShell's best-effort terminal notification for -// one opened stage stream. A stage receives at most one such notification and -// does not return a result. -message HttpResponseSessionEnd { - // Reason OpenShell is ending the stage stream. - HttpResponseSessionEndReason reason = 1; -} +// WebSocketProtocolError identifies a WebSocket protocol violation. Stable, +// actionable subcategories may be added later. +message WebSocketProtocolError {} + +// MiddlewareExchangeProtocolError identifies an invalid middleware +// event/result exchange. Stable, actionable subcategories may be added later. +message MiddlewareExchangeProtocolError {} // Supervisor operation selected for middleware evaluation. enum SupervisorMiddlewareOperation { @@ -407,24 +438,6 @@ enum SupervisorMiddlewarePhase { SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN = 2; } -// Why OpenShell is ending a middleware stream. -enum WebSocketSessionEndReason { - WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED = 0; - WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE = 1; - WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT = 2; - WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD = 3; - WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL = 4; - WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; - WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR = 6; - WEB_SOCKET_SESSION_END_REASON_CANCELLATION = 7; - WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED = 8; - WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL = 9; - // The middleware stage voluntarily declined inspection during preflight. - // This is a successful stage-local outcome, not a cancellation or denial of - // the WebSocket upgrade. - WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED = 10; -} - // WebSocketSessionEvent is one ordered event in a stage-local stream. // Message sequence numbers identify logical messages session-wide. A stage // receives a strictly increasing subset of those numbers; gaps are valid when @@ -434,7 +447,7 @@ message WebSocketSessionEvent { WebSocketPreflight preflight = 1; WebSocketSessionStart session_start = 2; WebSocketMessage message = 3; - WebSocketSessionEnd session_end = 4; + MiddlewareSessionEnd session_end = 4; } } @@ -475,12 +488,6 @@ message WebSocketMessage { } } -// WebSocketSessionEnd is OpenShell's best-effort terminal notification for one -// opened stage stream. A stage receives at most one such notification. -message WebSocketSessionEnd { - WebSocketSessionEndReason reason = 1; -} - // WebSocketPreflightAction is the service's one-time scoping decision. enum WebSocketPreflightAction { // Invalid response value handled according to the policy failure mode. From 185bb524b3242592b452673bb61de9dde6584a40 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Tue, 1 Sep 2026 16:49:16 -0700 Subject: [PATCH 06/24] feat(middleware)!: finalize HTTP response pre-return contract Replace the separate body_end event with HttpResponseBodyUnit.end_of_stream. Every body-inspecting stage receives exactly one flagged unit, which may be empty; a zero-byte body is one empty flagged unit and OpenShell never reads ahead to set the flag. Defer response trailers from V1 and reserve their field numbers. HTTP/1.0 clients and Content-Length bodies cannot carry trailers and that behavior was undefined. Add HttpResponsePreflight.permitted_body_modes, computed once from the original upstream head so every stage sees the same list, and make an unlisted selection a failure rather than a downgrade. Add the block_delivery preflight action as a successful decision enforced regardless of on_error. Expose Content-Length, Content-Encoding, and Content-Range read-only in preflight. Cap STREAM_BYTES input units at half of max_payload_bytes and permit deferring bytes across replacements only for fail_closed bindings, surfaced as deferral_permitted. Split PEER_DISCONNECT into DOWNSTREAM_DISCONNECT and UPSTREAM_DISCONNECT and attribute WebSocket relay failures by direction instead of a generic peer error. Compile the content-guard example in lint and branch checks so proto renames cannot break it silently. BREAKING CHANGE: WebSocketSessionEndReason and WebSocketSessionEnd are replaced by the shared MiddlewareSessionEndReason and MiddlewareSessionEnd. NORMAL_CLOSE is now NORMAL, UPSTREAM_REJECTED is now UPSTREAM_FAILURE, and PEER_DISCONNECT is split into DOWNSTREAM_DISCONNECT and UPSTREAM_DISCONNECT. Enum numbers are unchanged so binary wire compatibility is preserved; generated symbols and JSON names change. Signed-off-by: Piotr Mlocek --- .github/workflows/branch-checks.yml | 2 + .../src/lib.rs | 6 +- .../src/l7/websocket.rs | 134 ++++++++---- .../Cargo.lock | 1 + .../src/main.rs | 4 +- proto/supervisor_middleware.proto | 207 ++++++++++-------- tasks/rust.toml | 3 + 7 files changed, 216 insertions(+), 141 deletions(-) diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 958d0b53c0..5d4a98c61a 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -158,12 +158,14 @@ jobs: cargo fmt --all -- --check cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check + cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check - name: Lint run: | cargo clippy --workspace --all-targets -- -D warnings cargo clippy --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings cargo check --manifest-path examples/governance-interceptor/Cargo.toml --all-targets + cargo check --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets - name: Test env: diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index 0aac3384d3..7482dd1fc7 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -856,7 +856,7 @@ fn supported_binding(source: &str, binding: &MiddlewareBinding) -> Result Err(miette!( - "{source} advertises WEBSOCKET_MESSAGE/PRE_RETURN, which is reserved for PR 2" + "{source} advertises WEBSOCKET_MESSAGE/PRE_RETURN, which is not yet supported" )), _ => Err(miette!( "{source} advertises an unsupported middleware operation/phase pair" @@ -3722,8 +3722,8 @@ mod tests { manifest.bindings = vec![binding(SupervisorMiddlewarePhase::PreReturn)]; let error = validate_manifest_bindings("test WebSocket service", &manifest, None) - .expect_err("return-path binding stays reserved for PR 2"); - assert!(error.to_string().contains("reserved for PR 2")); + .expect_err("return-path WebSocket binding is not yet supported"); + assert!(error.to_string().contains("not yet supported")); } #[test] diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index a039a8909c..b7d5c67dbd 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -97,7 +97,8 @@ pub enum WebSocketAssemblyAdmissionOutcome { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum WebSocketTerminationCause { - PeerDisconnect, + DownstreamDisconnect, + UpstreamDisconnect, PolicyReload, CapacityExhausted, MiddlewareDenial, @@ -111,7 +112,7 @@ enum WebSocketTerminationCause { impl WebSocketTerminationCause { fn close_code(self) -> Option { match self { - Self::PeerDisconnect => None, + Self::DownstreamDisconnect | Self::UpstreamDisconnect => None, Self::PolicyReload => Some(1012), Self::CapacityExhausted => Some(1013), Self::MiddlewareDenial | Self::MiddlewareFailure | Self::PolicyDenial => Some(1008), @@ -123,8 +124,11 @@ impl WebSocketTerminationCause { fn session_end_reason(self) -> openshell_core::proto::MiddlewareSessionEndReason { match self { - Self::PeerDisconnect => { - openshell_core::proto::MiddlewareSessionEndReason::PeerDisconnect + Self::DownstreamDisconnect => { + openshell_core::proto::MiddlewareSessionEndReason::DownstreamDisconnect + } + Self::UpstreamDisconnect => { + openshell_core::proto::MiddlewareSessionEndReason::UpstreamDisconnect } Self::PolicyReload => openshell_core::proto::MiddlewareSessionEndReason::PolicyReload, Self::MiddlewareDenial => { @@ -189,7 +193,8 @@ enum AssemblyTimeoutKind { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FrameErrorKind { - PeerDisconnect, + DownstreamDisconnect, + UpstreamDisconnect, Protocol(FrameFailureClass), InvalidUtf8, MessageTooBig, @@ -209,20 +214,27 @@ impl std::fmt::Display for FrameError { } impl FrameError { - fn peer_io(context: &str, error: std::io::Error) -> Self { + fn client_io(error: std::io::Error) -> Self { Self { - kind: FrameErrorKind::PeerDisconnect, - error: miette!("{context}: {error}"), + kind: FrameErrorKind::DownstreamDisconnect, + error: miette!("websocket client read failed: {error}"), } } - fn peer_disconnect(error: miette::Report) -> Self { + fn client_disconnect(error: miette::Report) -> Self { Self { - kind: FrameErrorKind::PeerDisconnect, + kind: FrameErrorKind::DownstreamDisconnect, error, } } + fn upstream_io(context: &str, error: std::io::Error) -> Self { + Self { + kind: FrameErrorKind::UpstreamDisconnect, + error: miette!("{context}: {error}"), + } + } + fn protocol(failure_class: FrameFailureClass, error: miette::Report) -> Self { Self { kind: FrameErrorKind::Protocol(failure_class), @@ -263,7 +275,12 @@ impl FrameError { impl From for WebSocketTermination { fn from(frame_error: FrameError) -> Self { let (cause, failure_class) = match frame_error.kind { - FrameErrorKind::PeerDisconnect => (WebSocketTerminationCause::PeerDisconnect, None), + FrameErrorKind::DownstreamDisconnect => { + (WebSocketTerminationCause::DownstreamDisconnect, None) + } + FrameErrorKind::UpstreamDisconnect => { + (WebSocketTerminationCause::UpstreamDisconnect, None) + } FrameErrorKind::Protocol(failure_class) => ( WebSocketTerminationCause::ProtocolError, Some(failure_class), @@ -381,7 +398,7 @@ impl TextMessageAssembly { Err(FrameError::assembly_timeout(AssemblyTimeoutKind::Idle)) } result = reader.read(buffer) => { - result.map_err(|error| FrameError::peer_io("websocket client read failed", error)) + result.map_err(FrameError::client_io) }, } } @@ -395,7 +412,7 @@ impl TextMessageAssembly { while filled < buffer.len() { let read = self.read_some(reader, &mut buffer[filled..]).await?; if read == 0 { - return Err(FrameError::peer_disconnect(miette!( + return Err(FrameError::client_disconnect(miette!( "websocket payload ended before declared length" ))); } @@ -524,22 +541,35 @@ where &mut options, ); let server_to_client = async { - tokio::io::copy(&mut upstream_read, &mut client_write) - .await - .map_err(|error| { + let mut buf = vec![0u8; COPY_BUF_SIZE]; + loop { + let read = upstream_read.read(&mut buf).await.map_err(|error| { terminate( - WebSocketTerminationCause::PeerDisconnect, - miette!("websocket upstream relay ended: {error}"), + WebSocketTerminationCause::UpstreamDisconnect, + miette!("websocket upstream read failed: {error}"), ) })?; - client_write.flush().await.map_err(|error| { - terminate( - WebSocketTerminationCause::PeerDisconnect, - miette!("websocket client relay ended: {error}"), - ) - })?; + if read == 0 { + break; + } + client_write + .write_all(&buf[..read]) + .await + .map_err(|error| { + terminate( + WebSocketTerminationCause::DownstreamDisconnect, + miette!("websocket client write failed: {error}"), + ) + })?; + client_write.flush().await.map_err(|error| { + terminate( + WebSocketTerminationCause::DownstreamDisconnect, + miette!("websocket client flush failed: {error}"), + ) + })?; + } Ok::<_, WebSocketTermination>( - openshell_core::proto::MiddlewareSessionEndReason::PeerDisconnect, + openshell_core::proto::MiddlewareSessionEndReason::UpstreamDisconnect, ) }; @@ -639,7 +669,7 @@ where return Ok(if close_seen { openshell_core::proto::MiddlewareSessionEndReason::Normal } else { - openshell_core::proto::MiddlewareSessionEndReason::PeerDisconnect + openshell_core::proto::MiddlewareSessionEndReason::DownstreamDisconnect }); }; @@ -886,7 +916,7 @@ async fn read_exact_for_assembly( .read_exact(buffer) .await .map(|_| ()) - .map_err(|error| FrameError::peer_io("websocket client read failed", error)), + .map_err(FrameError::client_io), } } @@ -897,10 +927,7 @@ async fn read_frame_header( let mut first = [0u8; 1]; let first_read = match assembly { Some(assembly) => assembly.read_some(reader, &mut first).await, - None => reader - .read(&mut first) - .await - .map_err(|error| FrameError::peer_io("websocket client read failed", error)), + None => reader.read(&mut first).await.map_err(FrameError::client_io), }; let first = match first_read { Ok(0) => return Ok(None), @@ -1600,15 +1627,15 @@ where writer .write_all(&frame.raw_header) .await - .map_err(|error| FrameError::peer_io("websocket upstream write failed", error))?; + .map_err(|error| FrameError::upstream_io("websocket upstream write failed", error))?; writer .write_all(&raw_payload) .await - .map_err(|error| FrameError::peer_io("websocket upstream write failed", error))?; + .map_err(|error| FrameError::upstream_io("websocket upstream write failed", error))?; writer .flush() .await - .map_err(|error| FrameError::peer_io("websocket upstream flush failed", error))?; + .map_err(|error| FrameError::upstream_io("websocket upstream flush failed", error))?; Ok(()) } @@ -1654,7 +1681,7 @@ where writer .write_all(&frame.raw_header) .await - .map_err(|error| FrameError::peer_io("websocket upstream write failed", error))?; + .map_err(|error| FrameError::upstream_io("websocket upstream write failed", error))?; let mut remaining = frame.payload_len; let mut buf = [0u8; COPY_BUF_SIZE]; while remaining > 0 { @@ -1664,22 +1691,22 @@ where let n = reader .read(&mut buf[..to_read]) .await - .map_err(|error| FrameError::peer_io("websocket client read failed", error))?; + .map_err(FrameError::client_io)?; if n == 0 { - return Err(FrameError::peer_disconnect(miette!( + return Err(FrameError::client_disconnect(miette!( "websocket payload ended before declared length" ))); } writer .write_all(&buf[..n]) .await - .map_err(|error| FrameError::peer_io("websocket upstream write failed", error))?; + .map_err(|error| FrameError::upstream_io("websocket upstream write failed", error))?; remaining -= n as u64; } writer .flush() .await - .map_err(|error| FrameError::peer_io("websocket upstream flush failed", error))?; + .map_err(|error| FrameError::upstream_io("websocket upstream flush failed", error))?; Ok(()) } @@ -1762,19 +1789,19 @@ async fn write_text_frame_guarded( tokio::time::timeout(TEXT_MESSAGE_FORWARD_TOTAL_TIMEOUT, async { writer.write_all(header).await.map_err(|error| { terminate( - WebSocketTerminationCause::PeerDisconnect, + WebSocketTerminationCause::UpstreamDisconnect, miette!("websocket upstream write failed: {error}"), ) })?; writer.write_all(payload).await.map_err(|error| { terminate( - WebSocketTerminationCause::PeerDisconnect, + WebSocketTerminationCause::UpstreamDisconnect, miette!("websocket upstream write failed: {error}"), ) })?; writer.flush().await.map_err(|error| { terminate( - WebSocketTerminationCause::PeerDisconnect, + WebSocketTerminationCause::UpstreamDisconnect, miette!("websocket upstream flush failed: {error}"), ) }) @@ -1782,7 +1809,7 @@ async fn write_text_frame_guarded( .await .map_err(|_| { terminate( - WebSocketTerminationCause::PeerDisconnect, + WebSocketTerminationCause::UpstreamDisconnect, miette!("websocket upstream forwarding total timeout"), ) })??; @@ -2238,7 +2265,14 @@ network_policies: WebSocketTerminationCause::PolicyReload.close_code(), Some(1012) ); - assert_eq!(WebSocketTerminationCause::PeerDisconnect.close_code(), None); + assert_eq!( + WebSocketTerminationCause::DownstreamDisconnect.close_code(), + None + ); + assert_eq!( + WebSocketTerminationCause::UpstreamDisconnect.close_code(), + None + ); assert_eq!( WebSocketTerminationCause::InvalidUtf8.session_end_reason(), @@ -2260,6 +2294,14 @@ network_policies: WebSocketTerminationCause::PolicyReload.session_end_reason(), EndReason::PolicyReload ); + assert_eq!( + WebSocketTerminationCause::DownstreamDisconnect.session_end_reason(), + EndReason::DownstreamDisconnect + ); + assert_eq!( + WebSocketTerminationCause::UpstreamDisconnect.session_end_reason(), + EndReason::UpstreamDisconnect + ); } fn resolver() -> (HashMap, SecretResolver) { @@ -2603,7 +2645,7 @@ network_policies: .await .expect("join forwarding") .expect_err("non-reading upstream must time out"); - assert_eq!(error.cause, WebSocketTerminationCause::PeerDisconnect); + assert_eq!(error.cause, WebSocketTerminationCause::UpstreamDisconnect); assert!( error .error @@ -4018,7 +4060,7 @@ network_policies: assert!(matches!( observed.recv().await, Some(ObservedWebSocketRequest::SessionEnd( - openshell_core::proto::MiddlewareSessionEndReason::PeerDisconnect, + openshell_core::proto::MiddlewareSessionEndReason::DownstreamDisconnect, )) )); diff --git a/examples/supervisor-middleware-content-guard/Cargo.lock b/examples/supervisor-middleware-content-guard/Cargo.lock index 9bb8d1feee..f31d5be9b5 100644 --- a/examples/supervisor-middleware-content-guard/Cargo.lock +++ b/examples/supervisor-middleware-content-guard/Cargo.lock @@ -853,6 +853,7 @@ dependencies = [ "prost", "prost-types", "protoc-bin-vendored", + "rustix", "serde", "serde_json", "thiserror", diff --git a/examples/supervisor-middleware-content-guard/src/main.rs b/examples/supervisor-middleware-content-guard/src/main.rs index c527537e74..8d714264e7 100644 --- a/examples/supervisor-middleware-content-guard/src/main.rs +++ b/examples/supervisor-middleware-content-guard/src/main.rs @@ -478,7 +478,7 @@ async fn main() -> Result<(), Box> { #[cfg(test)] mod tests { use super::*; - use openshell_core::proto::{WebSocketPreflight, WebSocketSessionEnd, WebSocketSessionStart}; + use openshell_core::proto::{MiddlewareSessionEnd, WebSocketPreflight, WebSocketSessionStart}; use prost_types::{ListValue, Value}; use std::collections::BTreeMap; @@ -550,7 +550,7 @@ mod tests { )), })), event(web_socket_session_event::Event::SessionEnd( - WebSocketSessionEnd::default(), + MiddlewareSessionEnd::default(), )), ]); let mut results = ContentGuard::websocket_stream(events); diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 1032372acc..bfdcaf1bd9 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -8,9 +8,11 @@ package openshell.middleware.v1; import "google/protobuf/empty.proto"; import "google/protobuf/struct.proto"; -// SupervisorMiddleware lets an operator-run service inspect and transform -// sandbox HTTP requests and client WebSocket text messages before OpenShell -// injects credentials. +// SupervisorMiddleware is the operator-run discovery and configuration service +// for one middleware implementation. It also evaluates sandbox HTTP requests +// and client WebSocket text messages before OpenShell injects credentials. +// Phase-specific evaluation services such as HttpResponsePreReturn are served +// alongside it by the same registration. service SupervisorMiddleware { // Describe returns the service manifest and declared bindings. rpc Describe(google.protobuf.Empty) returns (MiddlewareManifest); @@ -41,9 +43,9 @@ service SupervisorMiddleware { // configuration validation. service HttpResponsePreReturn { // Evaluate opens one stage-local stream for one HTTP response. The first - // event is preflight. Body and trailer events follow only when selected by - // the preflight result. OpenShell attempts at most one session_end before - // closing the stream when its transport is still writable. + // event is preflight. Body units follow only when selected by the preflight + // result. OpenShell attempts at most one session_end before closing the + // stream when its transport is still writable. rpc Evaluate(stream HttpResponseEvent) returns (stream HttpResponseEventResult); } @@ -72,7 +74,8 @@ message MiddlewareManifest { message MiddlewareBinding { // Supported operation. SupervisorMiddlewareOperation operation = 1; - // Supported evaluation phase. + // Supported evaluation phase. WEBSOCKET_MESSAGE/PRE_RETURN is not yet + // supported and is rejected by manifest validation. SupervisorMiddlewarePhase phase = 2; // Maximum logical payload or replacement this binding can process. For // HTTP_REQUEST this is the request body; for HTTP_RESPONSE this is a whole @@ -137,36 +140,38 @@ message HttpHeader { // HttpResponseEvent is one ordered event in a stage-local response stream. A // stream starts with exactly one preflight. An inspecting body stage then -// receives zero or more body units, one body_end, and one trailers event. Skip -// and headers-only stages receive none of those events. OpenShell may finish -// any opened stream with one best-effort session_end. +// receives one or more body units, the last of which sets end_of_stream. Skip, +// headers-only, and block-delivery stages receive no body units. OpenShell may +// finish any opened stream with one best-effort session_end. message HttpResponseEvent { + // 3 was a separate body-end event, replaced by + // HttpResponseBodyUnit.end_of_stream. 4 is held for response trailers, + // which are deferred from V1. + reserved 3, 4; + reserved "body_end", "trailers"; oneof event { // Initial response head and request context for this stage. HttpResponsePreflight preflight = 1; // One normalized body unit selected by OpenShell. HttpResponseBodyUnit body = 2; - // Notification that OpenShell sent every body unit to this stage. - HttpResponseBodyEnd body_end = 3; - // Final normalized response trailers, including an empty trailer set. - HttpResponseTrailers trailers = 4; // Best-effort terminal notification for this stage stream. MiddlewareSessionEnd session_end = 5; } } -// HttpResponseEventResult acknowledges response preflight, body, or trailers. -// Results must follow event order. Every preflight, body, and trailers event -// requires exactly one matching result. Body end and session end do not -// produce results in V1. +// HttpResponseEventResult answers one preflight or body event. Results must +// follow event order. Every preflight and body event, including the final +// end_of_stream unit, requires exactly one matching result. Session end does +// not produce a result. message HttpResponseEventResult { + // 3 is held for response trailer results, which are deferred from V1. + reserved 3; + reserved "trailers_result"; oneof result { // Result for the stream's initial preflight. HttpResponsePreflightDecision preflight_decision = 1; // Result for the next outstanding body unit. HttpResponseBodyResult body_result = 2; - // Result for the stream's trailers event. - HttpResponseTrailersResult trailers_result = 3; } } @@ -183,9 +188,13 @@ message HttpResponsePreflight { // successful protocol upgrades are not evaluated through this service. uint32 status_code = 3; // Current end-to-end response headers after accepted mutations from earlier - // stages, in wire order. Repeated names remain separate entries. Protected - // routing, credential, framing, and hop-by-hop fields are omitted. At most - // 128 lines and 64 KiB of encoded headers are included. + // stages, in wire order. Repeated names remain separate entries. Credential, + // routing, and hop-by-hop fields are omitted. Content-Length, + // Content-Encoding, and Content-Range are included read-only as the upstream + // sent them: they are protected from mutation, so every stage sees the + // original values, and OpenShell may recompute or remove Content-Length + // downstream after transformation. At most 128 lines and 64 KiB of encoded + // headers are included. repeated HttpHeader headers = 4; // Built-in middleware name or operator-owned registration name. string middleware_name = 5; @@ -194,18 +203,41 @@ message HttpResponsePreflight { google.protobuf.Struct config = 6; // Effective minimum of the platform, registration, and binding limits. This // limits the complete input and replacement in WHOLE_BODY_BYTES, and each - // input unit and replacement in STREAM_BYTES. + // replacement in STREAM_BYTES. STREAM_BYTES input units are at most half of + // this value, so a replacement can carry a deferred tail of up to half of + // this value in addition to the unit's own replacement. uint64 max_payload_bytes = 7; -} - -// HttpResponsePreflightDecision either declines the response or selects an -// inspection mode and mutations. Diagnostics apply to either action. + // Body modes this stage may select for this response. OpenShell computes the + // list once from the original upstream head before any stage mutation, so + // every stage in the chain receives the same list. It always contains + // HEADERS_ONLY. Bodyless responses (HEAD, 204, 304), partial responses (206, + // Content-Range, multipart/byteranges), an original Cache-Control + // no-transform directive, and non-identity Content-Encoding permit + // HEADERS_ONLY only. A declared Content-Length above max_payload_bytes and + // media types defined as open-ended streams (text/event-stream, + // multipart/x-mixed-replace) omit WHOLE_BODY_BYTES. Selecting a mode that is + // not listed is a middleware failure handled according to on_error, never a + // silent downgrade. + repeated HttpResponseBodyMode permitted_body_modes = 8; + // True when this stage's binding is fail_closed. Only then may a STREAM_BYTES + // stage defer bytes from one unit's replacement to a later replacement, for + // example to hold a partial record while parsing a framed format. Deferred + // bytes exist only in the middleware, so a stage that failed open after + // deferring would silently corrupt the stream. Fail-open stages must account + // for each input unit fully in its own replacement. + bool deferral_permitted = 9; +} + +// HttpResponsePreflightDecision declines, inspects, or blocks delivery of the +// response. Diagnostics apply to any action. message HttpResponsePreflightDecision { oneof action { // Successfully decline this response without invoking on_error. HttpResponsePreflightSkip skip = 1; // Inspect the response using the selected body mode and mutations. HttpResponsePreflightInspect inspect = 2; + // Authoritatively prevent delivery of this response to the sandbox. + HttpResponsePreflightBlockDelivery block_delivery = 7; } // Free-form service diagnostic. OpenShell never exposes this to the sandbox // or security logs. Limited to 4 KiB before discarding. @@ -213,7 +245,8 @@ message HttpResponsePreflightDecision { // Optional stable machine-readable code for audit and diagnostic output. // Codes must start with a lowercase ASCII letter and contain only lowercase // ASCII letters, digits, and underscores, with a maximum length of 64 bytes. - // OpenShell never returns this code to the sandbox. + // OpenShell may return this code to the sandbox for block_delivery, as it + // does for request denials, and never returns it for skip or inspect. string reason_code = 4; // Audit-safe findings produced during preflight. At most 32 findings of at // most 4 KiB encoded each are accepted. @@ -224,16 +257,27 @@ message HttpResponsePreflightDecision { } // HttpResponsePreflightSkip ends this stage successfully for the current -// response. OpenShell sends no body, body-end, or trailer events to the stage. +// response. OpenShell sends no body units to the stage. message HttpResponsePreflightSkip {} +// HttpResponsePreflightBlockDelivery withholds the response from the sandbox. +// It is a successful decision enforced regardless of on_error and is distinct +// from a middleware failure. OpenShell replaces the upstream response with a +// platform-owned error that states the upstream request may have completed; +// this decision does not undo that request. A block from any stage wins over +// skip, inspect, and failures from other stages. Later stages are not opened, +// and every opened stage receives session_end with MIDDLEWARE_DENIAL. +message HttpResponsePreflightBlockDelivery {} + // HttpResponsePreflightInspect selects this stage's response inspection mode // and proposes mutations to the current response head. message HttpResponsePreflightInspect { - // Required response body mode. UNSPECIFIED and modes incompatible with the - // response semantics are middleware failures handled according to on_error. - // Only HEADERS_ONLY may inspect HEAD, 204, 304, partial, original - // no-transform, or non-identity content-encoded responses in V1. + // 3 is held for declared trailer names, deferred from V1 with trailers. + reserved 3; + reserved "declared_trailer_names"; + // Required response body mode. It must be one of + // HttpResponsePreflight.permitted_body_modes. UNSPECIFIED and unlisted modes + // are middleware failures handled according to on_error. HttpResponseBodyMode body_mode = 1; // Ordered response-header mutations applied atomically before the next stage. // Writes and removals may target permitted visible end-to-end headers. @@ -242,24 +286,25 @@ message HttpResponsePreflightInspect { // operations, 32 KiB of validated name/value data, and 64 KiB encoded are // accepted. repeated HeaderMutation header_mutations = 2; - // Lowercased trailer names this stage may add after observing the final body - // bytes. OpenShell validates the names before committing the response head. - // Names count against the header-mutation count and encoded-size limits. - repeated string declared_trailer_names = 3; } -// HttpResponseBodyMode controls which response-body events one stage receives. +// HttpResponseBodyMode controls which response-body units one stage receives. enum HttpResponseBodyMode { // Invalid response value handled according to the policy failure mode. HTTP_RESPONSE_BODY_MODE_UNSPECIFIED = 0; - // Inspect and mutate only the response head. The stage receives no body, - // body-end, or trailer events. + // Inspect and mutate only the response head. The stage receives no body + // units. HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY = 1; - // Receive the complete normalized response body as exactly one body unit. - // The whole input and its replacement must fit max_payload_bytes. + // Receive the complete normalized response body as exactly one body unit + // with end_of_stream set. The whole input and its replacement must fit + // max_payload_bytes. OpenShell buffers the body before committing the + // response head. It fails the stage with whole_body_over_capacity when the + // body exceeds the limit and with whole_body_accumulation_timeout when the + // complete body does not arrive within the platform accumulation deadline. HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES = 2; - // Receive zero or more normalized byte units. Each input and replacement must - // fit max_payload_bytes; the complete response may be larger. + // Receive one or more normalized byte units, the last of which sets + // end_of_stream. Each replacement must fit max_payload_bytes; the complete + // response may be larger and has no total deadline. HTTP_RESPONSE_BODY_MODE_STREAM_BYTES = 3; } @@ -272,9 +317,20 @@ message HttpResponseBodyUnit { // Normalized response bytes with no HTTP transfer-framing significance. // WHOLE_BODY_BYTES uses present empty data for an empty body so middleware // can replace it with nonempty data. STREAM_BYTES units are at most the - // smaller of 64 KiB and max_payload_bytes. + // smaller of 64 KiB and half of max_payload_bytes, and OpenShell may send + // shorter units to preserve upstream flush behavior. bytes data = 2; } + // True on the final unit for this stage. Every body-inspecting stage receives + // exactly one flagged unit; a zero-byte body is a single empty flagged unit + // at sequence 1. When the end of a chunked or close-delimited body is + // discovered after the last data, OpenShell sends an empty flagged unit + // rather than delaying the preceding unit. OpenShell never reads ahead to set + // this flag. The flagged unit requires a result like any other unit, and a + // stage that deferred bytes emits them in that result. A stage must not rely + // on receiving this unit: disconnects, upstream failures, policy reloads, and + // other stage failures end the stream with session_end instead. + bool end_of_stream = 3; } // HttpResponseBodyResult acknowledges and finalizes exactly one body unit. V1 @@ -316,52 +372,18 @@ message HttpResponseBodyPassThrough {} // HttpResponseBodyTransform replaces the complete corresponding input unit. message HttpResponseBodyTransform { // Exactly one replacement payload is required. Present empty data deletes the - // input unit. The replacement is limited to max_payload_bytes. + // input unit. The replacement is limited to max_payload_bytes and may carry + // fewer or more bytes than the input. When + // HttpResponsePreflight.deferral_permitted is true, a stage may withhold + // trailing bytes from this replacement and emit them in a later replacement, + // holding at most half of max_payload_bytes. Otherwise every replacement + // must fully account for its own input unit. oneof replacement { // Normalized replacement response bytes. bytes data = 1; } } -// HttpResponseBodyEnd reports that OpenShell sent every body unit to this stage. -// It requires no result because every preceding unit is already finalized. -message HttpResponseBodyEnd { - // Zero when STREAM_BYTES produced no units; otherwise the final input - // sequence. WHOLE_BODY_BYTES always ends at sequence 1, including for an - // empty representation. - uint64 final_sequence = 1; -} - -// HttpResponseTrailers contains the current normalized trailer set after body -// processing and accepted mutations from earlier stages. OpenShell sends this -// event even when the current trailer set is empty. -message HttpResponseTrailers { - // Current end-to-end trailer fields in wire order. Repeated names remain - // separate entries. The response-header count and encoded-size limits apply. - repeated HttpHeader headers = 1; -} - -// HttpResponseTrailersResult proposes mutations to the final response trailers. -message HttpResponseTrailersResult { - // Ordered trailer mutations applied atomically before the next stage. A stage - // may add only names declared by its preflight result. Protected response - // fields remain immutable. An empty list preserves the current trailer set. - repeated HeaderMutation trailer_mutations = 1; - // Free-form service diagnostic. OpenShell never exposes this to the sandbox - // or security logs. Limited to 4 KiB before discarding. - string reason = 2; - // Optional stable machine-readable code for audit and diagnostic output. - // Codes follow HttpResponsePreflightDecision.reason_code and are never - // returned to the sandbox. - string reason_code = 3; - // Audit-safe findings produced for the final trailers. At most 32 findings of - // at most 4 KiB encoded each are accepted. - repeated Finding findings = 4; - // Non-secret service-defined metadata included in diagnostics. At most 64 - // entries and 32 KiB of combined key/value data are accepted. - map metadata = 5; -} - // Why OpenShell is ending an opened middleware stage stream. This enum is a // stable lifecycle classification shared by every streaming middleware // protocol. Protocol-specific information belongs in MiddlewareSessionEnd. @@ -370,11 +392,12 @@ enum MiddlewareSessionEndReason { MIDDLEWARE_SESSION_END_REASON_UNSPECIFIED = 0; // The evaluated interaction completed normally. MIDDLEWARE_SESSION_END_REASON_NORMAL = 1; - // A downstream or upstream peer disconnected before processing completed. - MIDDLEWARE_SESSION_END_REASON_PEER_DISCONNECT = 2; + // The sandbox-side peer disconnected before processing completed. + MIDDLEWARE_SESSION_END_REASON_DOWNSTREAM_DISCONNECT = 2; // A policy reload replaced the active middleware chain. MIDDLEWARE_SESSION_END_REASON_POLICY_RELOAD = 3; - // A middleware stage authoritatively denied the operation. + // A middleware stage authoritatively denied the operation or blocked + // response delivery. MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_DENIAL = 4; // This or another selected middleware stage failed. MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; @@ -383,13 +406,17 @@ enum MiddlewareSessionEndReason { MIDDLEWARE_SESSION_END_REASON_PROTOCOL_ERROR = 6; // OpenShell cancelled middleware evaluation for another lifecycle reason. MIDDLEWARE_SESSION_END_REASON_CANCELLATION = 7; - // The upstream operation failed or was rejected before completion. + // The upstream rejected the operation or failed before producing a valid + // response head or accepted upgrade. MIDDLEWARE_SESSION_END_REASON_UPSTREAM_FAILURE = 8; // Network policy denied the operation. MIDDLEWARE_SESSION_END_REASON_POLICY_DENIAL = 9; // The middleware stage voluntarily declined inspection during preflight. // This is a successful stage-local outcome, not a cancellation or denial. MIDDLEWARE_SESSION_END_REASON_STAGE_SKIPPED = 10; + // The upstream peer disconnected after producing a valid response head or + // accepted upgrade, before processing completed. + MIDDLEWARE_SESSION_END_REASON_UPSTREAM_DISCONNECT = 11; } // MiddlewareSessionEnd is OpenShell's best-effort terminal notification for diff --git a/tasks/rust.toml b/tasks/rust.toml index 03b7ebb7ce..e62e22b3cf 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -15,6 +15,7 @@ run = [ "cargo clippy --workspace --all-targets -- -D warnings", "cargo clippy --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings", "cargo check --manifest-path examples/governance-interceptor/Cargo.toml --all-targets", + "cargo check --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 lint native" hide = true @@ -25,6 +26,7 @@ run = [ "cargo fmt --all", "cargo fmt --manifest-path e2e/rust/Cargo.toml --all", "cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all", + "cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all", ] hide = true @@ -34,6 +36,7 @@ run = [ "cargo fmt --all -- --check", "cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check", "cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check", + "cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check", ] hide = true From 2b3e948a05bbd9ff7f79a457fe6582e48d0cac23 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Tue, 1 Sep 2026 16:54:54 -0700 Subject: [PATCH 07/24] docs(middleware): describe skip as opting out of inspection Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index bfdcaf1bd9..122ebddbee 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -228,11 +228,12 @@ message HttpResponsePreflight { bool deferral_permitted = 9; } -// HttpResponsePreflightDecision declines, inspects, or blocks delivery of the -// response. Diagnostics apply to any action. +// HttpResponsePreflightDecision opts out of inspecting the response, inspects +// it, or blocks its delivery. Diagnostics apply to any action. message HttpResponsePreflightDecision { oneof action { - // Successfully decline this response without invoking on_error. + // Opt out of inspecting this response. The response is delivered + // unchanged by this stage and on_error is not invoked. HttpResponsePreflightSkip skip = 1; // Inspect the response using the selected body mode and mutations. HttpResponsePreflightInspect inspect = 2; From 5e1ae8f7969b0e74befd11dccc7dc4de56a30962 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Tue, 1 Sep 2026 17:02:25 -0700 Subject: [PATCH 08/24] feat(middleware): add body-phase block_delivery and skip_remaining actions Body results may now stop delivery or opt out of inspecting the rest of the response after a prefix. One HttpResponseBlockDelivery message is shared by preflight and body results and documents the difference between blocking before and after head commitment. Drop the field reservations, since nothing in this contract has shipped, and renumber session_end to close the gap. Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 83 +++++++++++++++++++------------ 1 file changed, 50 insertions(+), 33 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 122ebddbee..469ceee270 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -140,22 +140,18 @@ message HttpHeader { // HttpResponseEvent is one ordered event in a stage-local response stream. A // stream starts with exactly one preflight. An inspecting body stage then -// receives one or more body units, the last of which sets end_of_stream. Skip, -// headers-only, and block-delivery stages receive no body units. OpenShell may -// finish any opened stream with one best-effort session_end. +// receives body units until it returns skip_remaining or block_delivery, or +// until the unit that sets end_of_stream. Skip, headers-only, and +// block-delivery stages receive no body units. OpenShell may finish any opened +// stream with one best-effort session_end. message HttpResponseEvent { - // 3 was a separate body-end event, replaced by - // HttpResponseBodyUnit.end_of_stream. 4 is held for response trailers, - // which are deferred from V1. - reserved 3, 4; - reserved "body_end", "trailers"; oneof event { // Initial response head and request context for this stage. HttpResponsePreflight preflight = 1; // One normalized body unit selected by OpenShell. HttpResponseBodyUnit body = 2; // Best-effort terminal notification for this stage stream. - MiddlewareSessionEnd session_end = 5; + MiddlewareSessionEnd session_end = 3; } } @@ -164,9 +160,6 @@ message HttpResponseEvent { // end_of_stream unit, requires exactly one matching result. Session end does // not produce a result. message HttpResponseEventResult { - // 3 is held for response trailer results, which are deferred from V1. - reserved 3; - reserved "trailers_result"; oneof result { // Result for the stream's initial preflight. HttpResponsePreflightDecision preflight_decision = 1; @@ -238,7 +231,7 @@ message HttpResponsePreflightDecision { // Inspect the response using the selected body mode and mutations. HttpResponsePreflightInspect inspect = 2; // Authoritatively prevent delivery of this response to the sandbox. - HttpResponsePreflightBlockDelivery block_delivery = 7; + HttpResponseBlockDelivery block_delivery = 7; } // Free-form service diagnostic. OpenShell never exposes this to the sandbox // or security logs. Limited to 4 KiB before discarding. @@ -261,21 +254,22 @@ message HttpResponsePreflightDecision { // response. OpenShell sends no body units to the stage. message HttpResponsePreflightSkip {} -// HttpResponsePreflightBlockDelivery withholds the response from the sandbox. -// It is a successful decision enforced regardless of on_error and is distinct -// from a middleware failure. OpenShell replaces the upstream response with a -// platform-owned error that states the upstream request may have completed; -// this decision does not undo that request. A block from any stage wins over -// skip, inspect, and failures from other stages. Later stages are not opened, -// and every opened stage receives session_end with MIDDLEWARE_DENIAL. -message HttpResponsePreflightBlockDelivery {} +// HttpResponseBlockDelivery stops delivery of the response to the sandbox. It +// is a successful decision enforced regardless of on_error and is distinct +// from a middleware failure. Before the response head is committed, which is +// always the case at preflight and for WHOLE_BODY_BYTES results, OpenShell +// replaces the upstream response with a platform-owned error that states the +// upstream request may have completed. After commitment, which is the case for +// STREAM_BYTES results, OpenShell aborts delivery and the sandbox may have +// received a prefix. Neither outcome undoes the upstream request. A block from +// any stage wins over every other action and over failures from other stages. +// Later stages are not opened or receive no further units, and every opened +// stage receives session_end with MIDDLEWARE_DENIAL. +message HttpResponseBlockDelivery {} // HttpResponsePreflightInspect selects this stage's response inspection mode // and proposes mutations to the current response head. message HttpResponsePreflightInspect { - // 3 is held for declared trailer names, deferred from V1 with trailers. - reserved 3; - reserved "declared_trailer_names"; // Required response body mode. It must be one of // HttpResponsePreflight.permitted_body_modes. UNSPECIFIED and unlisted modes // are middleware failures handled according to on_error. @@ -322,9 +316,10 @@ message HttpResponseBodyUnit { // shorter units to preserve upstream flush behavior. bytes data = 2; } - // True on the final unit for this stage. Every body-inspecting stage receives - // exactly one flagged unit; a zero-byte body is a single empty flagged unit - // at sequence 1. When the end of a chunked or close-delimited body is + // True on the final unit for this stage. Every body-inspecting stage that + // does not end its participation early with skip_remaining or block_delivery + // receives exactly one flagged unit; a zero-byte body is a single empty + // flagged unit at sequence 1. When the end of a chunked or close-delimited body is // discovered after the last data, OpenShell sends an empty flagged unit // rather than delaying the preceding unit. OpenShell never reads ahead to set // this flag. The flagged unit requires a result like any other unit, and a @@ -336,21 +331,26 @@ message HttpResponseBodyUnit { // HttpResponseBodyResult acknowledges and finalizes exactly one body unit. V1 // processes units in lockstep and does not send the next unit to a stage until -// the current result has passed validation. V1 does not support denial or -// ownership transfer. +// the current result has passed validation. V1 does not support ownership +// transfer. message HttpResponseBodyResult { // Must exactly match the sequence of the next outstanding body unit. Zero, // gaps, duplicates, and regressions are invalid. uint64 sequence = 1; - // Exactly one delivery action is required. An unset action is a middleware - // failure, not an implicit request for another input unit. Actions are - // explicit rather than inferred from replacement presence so a later version - // can add ownership transfer without changing the RPC cardinality. + // Exactly one action is required. An unset action is a middleware failure, + // not an implicit request for another input unit. Actions are explicit + // rather than inferred from replacement presence so a later version can add + // ownership transfer without changing the RPC cardinality. oneof action { // Forward the corresponding input unit without modification. HttpResponseBodyPassThrough pass_through = 2; // Replace the complete corresponding input unit. HttpResponseBodyTransform transform = 3; + // Stop delivery of this response. See HttpResponseBlockDelivery for the + // difference between results before and after head commitment. + HttpResponseBlockDelivery block_delivery = 8; + // Finalize this unit, then opt out of inspecting the rest of the response. + HttpResponseBodySkipRemaining skip_remaining = 9; } // Free-form service diagnostic. OpenShell never exposes this to the sandbox // or security logs. Limited to 4 KiB before discarding. @@ -370,6 +370,23 @@ message HttpResponseBodyResult { // HttpResponseBodyPassThrough preserves the corresponding input unit exactly. message HttpResponseBodyPassThrough {} +// HttpResponseBodySkipRemaining finalizes the current unit and ends this +// stage's participation in the response. OpenShell forwards every later unit +// without sending it to this stage, other stages continue to evaluate those +// units, and this stage receives no end_of_stream unit. A stage that deferred +// bytes must emit them in the nested transform. Coverage records the remaining +// body as skipped by choice, distinct from a fail-open bypass. On the +// WHOLE_BODY_BYTES unit this is equivalent to the nested action alone. +message HttpResponseBodySkipRemaining { + // Exactly one action for the current unit is required. + oneof current { + // Forward the current unit without modification. + HttpResponseBodyPassThrough pass_through = 1; + // Replace the current unit, including any deferred bytes. + HttpResponseBodyTransform transform = 2; + } +} + // HttpResponseBodyTransform replaces the complete corresponding input unit. message HttpResponseBodyTransform { // Exactly one replacement payload is required. Present empty data deletes the From 840db089e4cfae2fedc7baf10cad64e02fb7f17f Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Tue, 1 Sep 2026 17:04:29 -0700 Subject: [PATCH 09/24] refactor(middleware): share HTTP body leaf messages across directions HttpBodyUnit, HttpBodyPassThrough, HttpBodyTransform, HttpBodySkipRemaining, and HttpBodyMode carry no response-specific semantics, so name them for reuse by the streaming request hook. Envelopes, results, preflight, and block_delivery stay response-specific because commitment semantics differ. Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 88 ++++++++++++++++--------------- 1 file changed, 46 insertions(+), 42 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 469ceee270..59e7d53814 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -149,7 +149,7 @@ message HttpResponseEvent { // Initial response head and request context for this stage. HttpResponsePreflight preflight = 1; // One normalized body unit selected by OpenShell. - HttpResponseBodyUnit body = 2; + HttpBodyUnit body = 2; // Best-effort terminal notification for this stage stream. MiddlewareSessionEnd session_end = 3; } @@ -211,7 +211,7 @@ message HttpResponsePreflight { // multipart/x-mixed-replace) omit WHOLE_BODY_BYTES. Selecting a mode that is // not listed is a middleware failure handled according to on_error, never a // silent downgrade. - repeated HttpResponseBodyMode permitted_body_modes = 8; + repeated HttpBodyMode permitted_body_modes = 8; // True when this stage's binding is fail_closed. Only then may a STREAM_BYTES // stage defer bytes from one unit's replacement to a later replacement, for // example to hold a partial record while parsing a framed format. Deferred @@ -272,8 +272,12 @@ message HttpResponseBlockDelivery {} message HttpResponsePreflightInspect { // Required response body mode. It must be one of // HttpResponsePreflight.permitted_body_modes. UNSPECIFIED and unlisted modes - // are middleware failures handled according to on_error. - HttpResponseBodyMode body_mode = 1; + // are middleware failures handled according to on_error. WHOLE_BODY_BYTES + // buffers the body before committing the response head and fails the stage + // with whole_body_over_capacity when the body exceeds max_payload_bytes or + // with whole_body_accumulation_timeout when the complete body does not + // arrive within the platform accumulation deadline. + HttpBodyMode body_mode = 1; // Ordered response-header mutations applied atomically before the next stage. // Writes and removals may target permitted visible end-to-end headers. // Routing, credential, framing, coding, range, and hop-by-hop fields remain @@ -283,37 +287,37 @@ message HttpResponsePreflightInspect { repeated HeaderMutation header_mutations = 2; } -// HttpResponseBodyMode controls which response-body units one stage receives. -enum HttpResponseBodyMode { - // Invalid response value handled according to the policy failure mode. - HTTP_RESPONSE_BODY_MODE_UNSPECIFIED = 0; - // Inspect and mutate only the response head. The stage receives no body +// HttpBodyMode controls which body units one stage receives. It is shared by +// HTTP request and response middleware; each hook defines which modes a stage +// may select for a given message. +enum HttpBodyMode { + // Invalid value handled according to the policy failure mode. + HTTP_BODY_MODE_UNSPECIFIED = 0; + // Inspect and mutate only the message head. The stage receives no body // units. - HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY = 1; - // Receive the complete normalized response body as exactly one body unit - // with end_of_stream set. The whole input and its replacement must fit - // max_payload_bytes. OpenShell buffers the body before committing the - // response head. It fails the stage with whole_body_over_capacity when the - // body exceeds the limit and with whole_body_accumulation_timeout when the - // complete body does not arrive within the platform accumulation deadline. - HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES = 2; + HTTP_BODY_MODE_HEADERS_ONLY = 1; + // Receive the complete normalized body as exactly one body unit with + // end_of_stream set. The whole input and its replacement must fit + // max_payload_bytes. + HTTP_BODY_MODE_WHOLE_BODY_BYTES = 2; // Receive one or more normalized byte units, the last of which sets // end_of_stream. Each replacement must fit max_payload_bytes; the complete - // response may be larger and has no total deadline. - HTTP_RESPONSE_BODY_MODE_STREAM_BYTES = 3; + // body may be larger and has no total deadline. + HTTP_BODY_MODE_STREAM_BYTES = 3; } -// HttpResponseBodyUnit contains one supervisor-defined logical body unit. Unit -// boundaries have no HTTP transport or application semantic meaning. -message HttpResponseBodyUnit { +// HttpBodyUnit contains one supervisor-defined logical body unit of an HTTP +// request or response. Unit boundaries have no HTTP transport or application +// semantic meaning. +message HttpBodyUnit { // Contiguous and stage-local, starting at 1. uint64 sequence = 1; oneof payload { - // Normalized response bytes with no HTTP transfer-framing significance. + // Normalized body bytes with no HTTP transfer-framing significance. // WHOLE_BODY_BYTES uses present empty data for an empty body so middleware // can replace it with nonempty data. STREAM_BYTES units are at most the // smaller of 64 KiB and half of max_payload_bytes, and OpenShell may send - // shorter units to preserve upstream flush behavior. + // shorter units to preserve flush behavior. bytes data = 2; } // True on the final unit for this stage. Every body-inspecting stage that @@ -343,14 +347,14 @@ message HttpResponseBodyResult { // ownership transfer without changing the RPC cardinality. oneof action { // Forward the corresponding input unit without modification. - HttpResponseBodyPassThrough pass_through = 2; + HttpBodyPassThrough pass_through = 2; // Replace the complete corresponding input unit. - HttpResponseBodyTransform transform = 3; + HttpBodyTransform transform = 3; // Stop delivery of this response. See HttpResponseBlockDelivery for the // difference between results before and after head commitment. HttpResponseBlockDelivery block_delivery = 8; // Finalize this unit, then opt out of inspecting the rest of the response. - HttpResponseBodySkipRemaining skip_remaining = 9; + HttpBodySkipRemaining skip_remaining = 9; } // Free-form service diagnostic. OpenShell never exposes this to the sandbox // or security logs. Limited to 4 KiB before discarding. @@ -367,37 +371,37 @@ message HttpResponseBodyResult { map metadata = 7; } -// HttpResponseBodyPassThrough preserves the corresponding input unit exactly. -message HttpResponseBodyPassThrough {} +// HttpBodyPassThrough preserves the corresponding input unit exactly. +message HttpBodyPassThrough {} -// HttpResponseBodySkipRemaining finalizes the current unit and ends this -// stage's participation in the response. OpenShell forwards every later unit +// HttpBodySkipRemaining finalizes the current unit and ends this stage's +// participation in the body. OpenShell forwards every later unit // without sending it to this stage, other stages continue to evaluate those // units, and this stage receives no end_of_stream unit. A stage that deferred // bytes must emit them in the nested transform. Coverage records the remaining // body as skipped by choice, distinct from a fail-open bypass. On the // WHOLE_BODY_BYTES unit this is equivalent to the nested action alone. -message HttpResponseBodySkipRemaining { +message HttpBodySkipRemaining { // Exactly one action for the current unit is required. oneof current { // Forward the current unit without modification. - HttpResponseBodyPassThrough pass_through = 1; + HttpBodyPassThrough pass_through = 1; // Replace the current unit, including any deferred bytes. - HttpResponseBodyTransform transform = 2; + HttpBodyTransform transform = 2; } } -// HttpResponseBodyTransform replaces the complete corresponding input unit. -message HttpResponseBodyTransform { +// HttpBodyTransform replaces the complete corresponding input unit. +message HttpBodyTransform { // Exactly one replacement payload is required. Present empty data deletes the // input unit. The replacement is limited to max_payload_bytes and may carry - // fewer or more bytes than the input. When - // HttpResponsePreflight.deferral_permitted is true, a stage may withhold - // trailing bytes from this replacement and emit them in a later replacement, - // holding at most half of max_payload_bytes. Otherwise every replacement - // must fully account for its own input unit. + // fewer or more bytes than the input. When the hook's preflight reports + // deferral_permitted, a stage may withhold trailing bytes from this + // replacement and emit them in a later replacement, holding at most half of + // max_payload_bytes. Otherwise every replacement must fully account for its + // own input unit. oneof replacement { - // Normalized replacement response bytes. + // Normalized replacement body bytes. bytes data = 1; } } From 0d1c6f35ec09d6b167c07f34ffbefd12778123f2 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Wed, 2 Sep 2026 09:54:41 -0700 Subject: [PATCH 10/24] refactor(middleware): keep HTTP body leaf messages response-specific Reverts the shared HttpBody* naming. A direction-specific payload such as a response-only semantic mode would otherwise add unreachable variants to the other direction or force a source-breaking fork after 0.1.0. The streaming request hook defines its own HttpRequestBody* messages and copies the shape; SDKs present a direction-neutral body handler over both. Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 88 +++++++++++++++---------------- 1 file changed, 42 insertions(+), 46 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 59e7d53814..469ceee270 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -149,7 +149,7 @@ message HttpResponseEvent { // Initial response head and request context for this stage. HttpResponsePreflight preflight = 1; // One normalized body unit selected by OpenShell. - HttpBodyUnit body = 2; + HttpResponseBodyUnit body = 2; // Best-effort terminal notification for this stage stream. MiddlewareSessionEnd session_end = 3; } @@ -211,7 +211,7 @@ message HttpResponsePreflight { // multipart/x-mixed-replace) omit WHOLE_BODY_BYTES. Selecting a mode that is // not listed is a middleware failure handled according to on_error, never a // silent downgrade. - repeated HttpBodyMode permitted_body_modes = 8; + repeated HttpResponseBodyMode permitted_body_modes = 8; // True when this stage's binding is fail_closed. Only then may a STREAM_BYTES // stage defer bytes from one unit's replacement to a later replacement, for // example to hold a partial record while parsing a framed format. Deferred @@ -272,12 +272,8 @@ message HttpResponseBlockDelivery {} message HttpResponsePreflightInspect { // Required response body mode. It must be one of // HttpResponsePreflight.permitted_body_modes. UNSPECIFIED and unlisted modes - // are middleware failures handled according to on_error. WHOLE_BODY_BYTES - // buffers the body before committing the response head and fails the stage - // with whole_body_over_capacity when the body exceeds max_payload_bytes or - // with whole_body_accumulation_timeout when the complete body does not - // arrive within the platform accumulation deadline. - HttpBodyMode body_mode = 1; + // are middleware failures handled according to on_error. + HttpResponseBodyMode body_mode = 1; // Ordered response-header mutations applied atomically before the next stage. // Writes and removals may target permitted visible end-to-end headers. // Routing, credential, framing, coding, range, and hop-by-hop fields remain @@ -287,37 +283,37 @@ message HttpResponsePreflightInspect { repeated HeaderMutation header_mutations = 2; } -// HttpBodyMode controls which body units one stage receives. It is shared by -// HTTP request and response middleware; each hook defines which modes a stage -// may select for a given message. -enum HttpBodyMode { - // Invalid value handled according to the policy failure mode. - HTTP_BODY_MODE_UNSPECIFIED = 0; - // Inspect and mutate only the message head. The stage receives no body +// HttpResponseBodyMode controls which response-body units one stage receives. +enum HttpResponseBodyMode { + // Invalid response value handled according to the policy failure mode. + HTTP_RESPONSE_BODY_MODE_UNSPECIFIED = 0; + // Inspect and mutate only the response head. The stage receives no body // units. - HTTP_BODY_MODE_HEADERS_ONLY = 1; - // Receive the complete normalized body as exactly one body unit with - // end_of_stream set. The whole input and its replacement must fit - // max_payload_bytes. - HTTP_BODY_MODE_WHOLE_BODY_BYTES = 2; + HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY = 1; + // Receive the complete normalized response body as exactly one body unit + // with end_of_stream set. The whole input and its replacement must fit + // max_payload_bytes. OpenShell buffers the body before committing the + // response head. It fails the stage with whole_body_over_capacity when the + // body exceeds the limit and with whole_body_accumulation_timeout when the + // complete body does not arrive within the platform accumulation deadline. + HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES = 2; // Receive one or more normalized byte units, the last of which sets // end_of_stream. Each replacement must fit max_payload_bytes; the complete - // body may be larger and has no total deadline. - HTTP_BODY_MODE_STREAM_BYTES = 3; + // response may be larger and has no total deadline. + HTTP_RESPONSE_BODY_MODE_STREAM_BYTES = 3; } -// HttpBodyUnit contains one supervisor-defined logical body unit of an HTTP -// request or response. Unit boundaries have no HTTP transport or application -// semantic meaning. -message HttpBodyUnit { +// HttpResponseBodyUnit contains one supervisor-defined logical body unit. Unit +// boundaries have no HTTP transport or application semantic meaning. +message HttpResponseBodyUnit { // Contiguous and stage-local, starting at 1. uint64 sequence = 1; oneof payload { - // Normalized body bytes with no HTTP transfer-framing significance. + // Normalized response bytes with no HTTP transfer-framing significance. // WHOLE_BODY_BYTES uses present empty data for an empty body so middleware // can replace it with nonempty data. STREAM_BYTES units are at most the // smaller of 64 KiB and half of max_payload_bytes, and OpenShell may send - // shorter units to preserve flush behavior. + // shorter units to preserve upstream flush behavior. bytes data = 2; } // True on the final unit for this stage. Every body-inspecting stage that @@ -347,14 +343,14 @@ message HttpResponseBodyResult { // ownership transfer without changing the RPC cardinality. oneof action { // Forward the corresponding input unit without modification. - HttpBodyPassThrough pass_through = 2; + HttpResponseBodyPassThrough pass_through = 2; // Replace the complete corresponding input unit. - HttpBodyTransform transform = 3; + HttpResponseBodyTransform transform = 3; // Stop delivery of this response. See HttpResponseBlockDelivery for the // difference between results before and after head commitment. HttpResponseBlockDelivery block_delivery = 8; // Finalize this unit, then opt out of inspecting the rest of the response. - HttpBodySkipRemaining skip_remaining = 9; + HttpResponseBodySkipRemaining skip_remaining = 9; } // Free-form service diagnostic. OpenShell never exposes this to the sandbox // or security logs. Limited to 4 KiB before discarding. @@ -371,37 +367,37 @@ message HttpResponseBodyResult { map metadata = 7; } -// HttpBodyPassThrough preserves the corresponding input unit exactly. -message HttpBodyPassThrough {} +// HttpResponseBodyPassThrough preserves the corresponding input unit exactly. +message HttpResponseBodyPassThrough {} -// HttpBodySkipRemaining finalizes the current unit and ends this stage's -// participation in the body. OpenShell forwards every later unit +// HttpResponseBodySkipRemaining finalizes the current unit and ends this +// stage's participation in the response. OpenShell forwards every later unit // without sending it to this stage, other stages continue to evaluate those // units, and this stage receives no end_of_stream unit. A stage that deferred // bytes must emit them in the nested transform. Coverage records the remaining // body as skipped by choice, distinct from a fail-open bypass. On the // WHOLE_BODY_BYTES unit this is equivalent to the nested action alone. -message HttpBodySkipRemaining { +message HttpResponseBodySkipRemaining { // Exactly one action for the current unit is required. oneof current { // Forward the current unit without modification. - HttpBodyPassThrough pass_through = 1; + HttpResponseBodyPassThrough pass_through = 1; // Replace the current unit, including any deferred bytes. - HttpBodyTransform transform = 2; + HttpResponseBodyTransform transform = 2; } } -// HttpBodyTransform replaces the complete corresponding input unit. -message HttpBodyTransform { +// HttpResponseBodyTransform replaces the complete corresponding input unit. +message HttpResponseBodyTransform { // Exactly one replacement payload is required. Present empty data deletes the // input unit. The replacement is limited to max_payload_bytes and may carry - // fewer or more bytes than the input. When the hook's preflight reports - // deferral_permitted, a stage may withhold trailing bytes from this - // replacement and emit them in a later replacement, holding at most half of - // max_payload_bytes. Otherwise every replacement must fully account for its - // own input unit. + // fewer or more bytes than the input. When + // HttpResponsePreflight.deferral_permitted is true, a stage may withhold + // trailing bytes from this replacement and emit them in a later replacement, + // holding at most half of max_payload_bytes. Otherwise every replacement + // must fully account for its own input unit. oneof replacement { - // Normalized replacement body bytes. + // Normalized replacement response bytes. bytes data = 1; } } From c221d8dd61f87c8948c3947ff9b9ad42e8169dfa Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Wed, 2 Sep 2026 10:57:32 -0700 Subject: [PATCH 11/24] docs(middleware): simplify response proto comments Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 354 +++++++++++------------------- 1 file changed, 128 insertions(+), 226 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 469ceee270..b864cd36c5 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -8,11 +8,9 @@ package openshell.middleware.v1; import "google/protobuf/empty.proto"; import "google/protobuf/struct.proto"; -// SupervisorMiddleware is the operator-run discovery and configuration service -// for one middleware implementation. It also evaluates sandbox HTTP requests -// and client WebSocket text messages before OpenShell injects credentials. -// Phase-specific evaluation services such as HttpResponsePreReturn are served -// alongside it by the same registration. +// SupervisorMiddleware discovers and configures one operator-run middleware. +// It evaluates HTTP requests and WebSocket messages before credentials. +// Phase-specific services share the same registration. service SupervisorMiddleware { // Describe returns the service manifest and declared bindings. rpc Describe(google.protobuf.Empty) returns (MiddlewareManifest); @@ -35,24 +33,16 @@ service SupervisorMiddleware { returns (stream WebSocketSessionEventResult); } -// HttpResponsePreReturn evaluates one ordered response stream for one selected -// middleware stage after OpenShell receives the final upstream response head -// and before it returns that response to the sandbox. -// The operator registration serves this phase-specific service alongside -// SupervisorMiddleware, which remains responsible for discovery and -// configuration validation. +// HttpResponsePreReturn evaluates one response for one middleware stage before +// OpenShell returns it to the sandbox. service HttpResponsePreReturn { - // Evaluate opens one stage-local stream for one HTTP response. The first - // event is preflight. Body units follow only when selected by the preflight - // result. OpenShell attempts at most one session_end before closing the - // stream when its transport is still writable. + // Evaluate starts with preflight, followed by selected body units. It may end + // with one best-effort session_end. rpc Evaluate(stream HttpResponseEvent) returns (stream HttpResponseEventResult); } -// MiddlewareManifest describes one middleware service and the bindings it -// exposes. The operator-run gRPC server implements SupervisorMiddleware and -// any phase-specific evaluation service required by its declared bindings. +// MiddlewareManifest describes one middleware service and its bindings. message MiddlewareManifest { // Human-readable middleware service name used only for diagnostics. This is // not required to match an operator-owned registration name. @@ -74,13 +64,10 @@ message MiddlewareManifest { message MiddlewareBinding { // Supported operation. SupervisorMiddlewareOperation operation = 1; - // Supported evaluation phase. WEBSOCKET_MESSAGE/PRE_RETURN is not yet - // supported and is rejected by manifest validation. + // Supported phase. WEBSOCKET_MESSAGE/PRE_RETURN is rejected. SupervisorMiddlewarePhase phase = 2; - // Maximum logical payload or replacement this binding can process. For - // HTTP_REQUEST this is the request body; for HTTP_RESPONSE this is a whole - // body or one streaming input/replacement unit; for WEBSOCKET_MESSAGE this - // is one complete message. Required for every payload-bearing operation. + // Maximum request body, WebSocket message, or response body unit/replacement. + // Required for payload-bearing operations. uint64 max_payload_bytes = 3; // Optional binding-specific RPC timeout. Empty uses the operator-configured // service timeout, or the 500ms platform default when that is also omitted. @@ -138,334 +125,249 @@ message HttpHeader { string value = 2; } -// HttpResponseEvent is one ordered event in a stage-local response stream. A -// stream starts with exactly one preflight. An inspecting body stage then -// receives body units until it returns skip_remaining or block_delivery, or -// until the unit that sets end_of_stream. Skip, headers-only, and -// block-delivery stages receive no body units. OpenShell may finish any opened -// stream with one best-effort session_end. +// One ordered response event. A stream starts with preflight, may continue with +// body units, and may end with one best-effort session_end. message HttpResponseEvent { oneof event { - // Initial response head and request context for this stage. + // Initial response head and request context. HttpResponsePreflight preflight = 1; - // One normalized body unit selected by OpenShell. + // Next normalized body unit. HttpResponseBodyUnit body = 2; - // Best-effort terminal notification for this stage stream. + // Optional terminal notification. MiddlewareSessionEnd session_end = 3; } } -// HttpResponseEventResult answers one preflight or body event. Results must -// follow event order. Every preflight and body event, including the final -// end_of_stream unit, requires exactly one matching result. Session end does -// not produce a result. +// Each preflight and body event requires one ordered result. session_end has no +// result. message HttpResponseEventResult { oneof result { - // Result for the stream's initial preflight. + // Result for preflight. HttpResponsePreflightDecision preflight_decision = 1; - // Result for the next outstanding body unit. + // Result for the next body unit. HttpResponseBodyResult body_result = 2; } } // HttpResponsePreflight exposes the current final response head to one stage. message HttpResponsePreflight { - // Sandbox and request identity shared with request middleware. The request_id - // correlates the request and response evaluations. The encoded context is - // limited to 4 KiB. + // Request identity. request_id links request and response evaluations. + // Limited to 4 KiB encoded. RequestContext context = 1; - // Admitted request destination, method, path, and redacted query. The encoded - // target is limited to 32 KiB. + // Admitted request target with a redacted query. Limited to 32 KiB encoded. HttpRequestTarget target = 2; - // Final non-informational upstream HTTP status code. Interim responses and - // successful protocol upgrades are not evaluated through this service. + // Final non-informational upstream status. Upgrades are not evaluated. uint32 status_code = 3; - // Current end-to-end response headers after accepted mutations from earlier - // stages, in wire order. Repeated names remain separate entries. Credential, - // routing, and hop-by-hop fields are omitted. Content-Length, - // Content-Encoding, and Content-Range are included read-only as the upstream - // sent them: they are protected from mutation, so every stage sees the - // original values, and OpenShell may recompute or remove Content-Length - // downstream after transformation. At most 128 lines and 64 KiB of encoded - // headers are included. + // Response headers after prior stages, in wire order. Repeated names remain + // separate. Credential, routing, and hop-by-hop headers are omitted. + // Content-Length, Content-Encoding, and Content-Range retain their read-only + // upstream values. OpenShell may recompute or remove Content-Length later. + // Limited to 128 lines and 64 KiB encoded. repeated HttpHeader headers = 4; // Built-in middleware name or operator-owned registration name. string middleware_name = 5; - // Validated service-specific policy configuration. The encoded configuration - // is limited to 64 KiB. + // Validated service configuration. Limited to 64 KiB encoded. google.protobuf.Struct config = 6; - // Effective minimum of the platform, registration, and binding limits. This - // limits the complete input and replacement in WHOLE_BODY_BYTES, and each - // replacement in STREAM_BYTES. STREAM_BYTES input units are at most half of - // this value, so a replacement can carry a deferred tail of up to half of - // this value in addition to the unit's own replacement. + // Effective minimum of platform, registration, and binding limits. Applies to + // whole-body input/replacement and each stream replacement. Stream inputs use + // at most half this limit. uint64 max_payload_bytes = 7; - // Body modes this stage may select for this response. OpenShell computes the - // list once from the original upstream head before any stage mutation, so - // every stage in the chain receives the same list. It always contains - // HEADERS_ONLY. Bodyless responses (HEAD, 204, 304), partial responses (206, - // Content-Range, multipart/byteranges), an original Cache-Control - // no-transform directive, and non-identity Content-Encoding permit - // HEADERS_ONLY only. A declared Content-Length above max_payload_bytes and - // media types defined as open-ended streams (text/event-stream, - // multipart/x-mixed-replace) omit WHOLE_BODY_BYTES. Selecting a mode that is - // not listed is a middleware failure handled according to on_error, never a - // silent downgrade. + // Modes computed once from the original response head. HEADERS_ONLY is always + // present and is the only mode for bodyless, partial, encoded, or no-transform + // responses. Oversized or open-ended responses omit WHOLE_BODY_BYTES. + // Selecting an unlisted mode fails according to on_error. repeated HttpResponseBodyMode permitted_body_modes = 8; - // True when this stage's binding is fail_closed. Only then may a STREAM_BYTES - // stage defer bytes from one unit's replacement to a later replacement, for - // example to hold a partial record while parsing a framed format. Deferred - // bytes exist only in the middleware, so a stage that failed open after - // deferring would silently corrupt the stream. Fail-open stages must account - // for each input unit fully in its own replacement. + // Allows STREAM_BYTES replacements to defer bytes across units. Set only for + // fail-closed stages; fail-open stages cannot defer. bool deferral_permitted = 9; } -// HttpResponsePreflightDecision opts out of inspecting the response, inspects -// it, or blocks its delivery. Diagnostics apply to any action. +// Selects skip, inspect, or block. Diagnostic fields apply to every action. message HttpResponsePreflightDecision { oneof action { - // Opt out of inspecting this response. The response is delivered - // unchanged by this stage and on_error is not invoked. + // Deliver unchanged without invoking on_error. HttpResponsePreflightSkip skip = 1; - // Inspect the response using the selected body mode and mutations. + // Inspect with the selected body mode and mutations. HttpResponsePreflightInspect inspect = 2; - // Authoritatively prevent delivery of this response to the sandbox. + // Prevent delivery to the sandbox. HttpResponseBlockDelivery block_delivery = 7; } - // Free-form service diagnostic. OpenShell never exposes this to the sandbox - // or security logs. Limited to 4 KiB before discarding. + // Service diagnostic, never sent to the sandbox or security logs. Maximum + // 4 KiB. string reason = 3; - // Optional stable machine-readable code for audit and diagnostic output. - // Codes must start with a lowercase ASCII letter and contain only lowercase - // ASCII letters, digits, and underscores, with a maximum length of 64 bytes. - // OpenShell may return this code to the sandbox for block_delivery, as it - // does for request denials, and never returns it for skip or inspect. + // Optional audit code using HttpRequestResult.reason_code format. Returned to + // the sandbox only for block_delivery. string reason_code = 4; - // Audit-safe findings produced during preflight. At most 32 findings of at - // most 4 KiB encoded each are accepted. + // Up to 32 audit-safe findings, each limited to 4 KiB encoded. repeated Finding findings = 5; - // Non-secret service-defined metadata included in diagnostics. At most 64 - // entries and 32 KiB of combined key/value data are accepted. + // Non-secret diagnostic metadata, limited to 64 entries and 32 KiB. map metadata = 6; } -// HttpResponsePreflightSkip ends this stage successfully for the current -// response. OpenShell sends no body units to the stage. +// Ends this stage successfully without body inspection. message HttpResponsePreflightSkip {} -// HttpResponseBlockDelivery stops delivery of the response to the sandbox. It -// is a successful decision enforced regardless of on_error and is distinct -// from a middleware failure. Before the response head is committed, which is -// always the case at preflight and for WHOLE_BODY_BYTES results, OpenShell -// replaces the upstream response with a platform-owned error that states the -// upstream request may have completed. After commitment, which is the case for -// STREAM_BYTES results, OpenShell aborts delivery and the sandbox may have -// received a prefix. Neither outcome undoes the upstream request. A block from -// any stage wins over every other action and over failures from other stages. -// Later stages are not opened or receive no further units, and every opened -// stage receives session_end with MIDDLEWARE_DENIAL. +// Blocks delivery as a successful decision regardless of on_error. Preflight +// and WHOLE_BODY_BYTES blocks replace the uncommitted response with a platform +// error. STREAM_BYTES blocks abort delivery after any sent prefix. The upstream +// request is not undone. A block wins over other actions and failures, stops +// later evaluation, and ends opened stages with MIDDLEWARE_DENIAL. message HttpResponseBlockDelivery {} -// HttpResponsePreflightInspect selects this stage's response inspection mode -// and proposes mutations to the current response head. +// Selects body inspection and response-header mutations. message HttpResponsePreflightInspect { - // Required response body mode. It must be one of - // HttpResponsePreflight.permitted_body_modes. UNSPECIFIED and unlisted modes - // are middleware failures handled according to on_error. + // Required mode from permitted_body_modes. Invalid values fail according to + // on_error. HttpResponseBodyMode body_mode = 1; - // Ordered response-header mutations applied atomically before the next stage. - // Writes and removals may target permitted visible end-to-end headers. - // Routing, credential, framing, coding, range, and hop-by-hop fields remain - // protected. Integrity fields may be removed but not written. At most 64 - // operations, 32 KiB of validated name/value data, and 64 KiB encoded are - // accepted. + // Ordered mutations applied atomically before the next stage. Only visible + // end-to-end headers may change. Routing, credential, framing, coding, range, + // and hop-by-hop headers are protected; integrity headers may only be removed. + // Limited to 64 operations, 32 KiB of name/value data, and 64 KiB encoded. repeated HeaderMutation header_mutations = 2; } -// HttpResponseBodyMode controls which response-body units one stage receives. +// Controls which response-body units a stage receives. enum HttpResponseBodyMode { - // Invalid response value handled according to the policy failure mode. + // Invalid value handled according to on_error. HTTP_RESPONSE_BODY_MODE_UNSPECIFIED = 0; - // Inspect and mutate only the response head. The stage receives no body - // units. + // Inspect only the response head. HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY = 1; - // Receive the complete normalized response body as exactly one body unit - // with end_of_stream set. The whole input and its replacement must fit - // max_payload_bytes. OpenShell buffers the body before committing the - // response head. It fails the stage with whole_body_over_capacity when the - // body exceeds the limit and with whole_body_accumulation_timeout when the - // complete body does not arrive within the platform accumulation deadline. + // Buffer the normalized body as one final unit before committing the head. + // Input and replacement must fit max_payload_bytes. Capacity and deadline + // failures use whole_body_over_capacity and whole_body_accumulation_timeout. HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES = 2; - // Receive one or more normalized byte units, the last of which sets - // end_of_stream. Each replacement must fit max_payload_bytes; the complete - // response may be larger and has no total deadline. + // Receive normalized units ending with end_of_stream. Each replacement must + // fit max_payload_bytes. The full body may exceed it and has no accumulation + // deadline. HTTP_RESPONSE_BODY_MODE_STREAM_BYTES = 3; } -// HttpResponseBodyUnit contains one supervisor-defined logical body unit. Unit -// boundaries have no HTTP transport or application semantic meaning. +// One normalized body unit. Boundaries have no transport or application +// meaning. message HttpResponseBodyUnit { // Contiguous and stage-local, starting at 1. uint64 sequence = 1; oneof payload { - // Normalized response bytes with no HTTP transfer-framing significance. - // WHOLE_BODY_BYTES uses present empty data for an empty body so middleware - // can replace it with nonempty data. STREAM_BYTES units are at most the - // smaller of 64 KiB and half of max_payload_bytes, and OpenShell may send - // shorter units to preserve upstream flush behavior. + // Bytes without transfer framing. WHOLE_BODY_BYTES represents an empty body + // with present empty data. STREAM_BYTES units are at most the smaller of + // 64 KiB and half max_payload_bytes, and may be shorter to preserve flushing. bytes data = 2; } - // True on the final unit for this stage. Every body-inspecting stage that - // does not end its participation early with skip_remaining or block_delivery - // receives exactly one flagged unit; a zero-byte body is a single empty - // flagged unit at sequence 1. When the end of a chunked or close-delimited body is - // discovered after the last data, OpenShell sends an empty flagged unit - // rather than delaying the preceding unit. OpenShell never reads ahead to set - // this flag. The flagged unit requires a result like any other unit, and a - // stage that deferred bytes emits them in that result. A stage must not rely - // on receiving this unit: disconnects, upstream failures, policy reloads, and - // other stage failures end the stream with session_end instead. + // Marks the final unit. A completed inspection receives exactly one, including + // an empty sequence-1 unit for an empty body. OpenShell does not read ahead, + // so it may send an empty final unit after the last data unit. The final unit + // requires a result containing any deferred bytes. Interrupted streams may + // end with session_end instead. bool end_of_stream = 3; } -// HttpResponseBodyResult acknowledges and finalizes exactly one body unit. V1 -// processes units in lockstep and does not send the next unit to a stage until -// the current result has passed validation. V1 does not support ownership -// transfer. +// Result for one body unit. Units are processed in lockstep; V1 does not +// support ownership transfer. message HttpResponseBodyResult { - // Must exactly match the sequence of the next outstanding body unit. Zero, - // gaps, duplicates, and regressions are invalid. + // Must match the next unit. Zero, gaps, duplicates, and regressions fail. uint64 sequence = 1; - // Exactly one action is required. An unset action is a middleware failure, - // not an implicit request for another input unit. Actions are explicit - // rather than inferred from replacement presence so a later version can add - // ownership transfer without changing the RPC cardinality. + // Exactly one explicit action is required. oneof action { - // Forward the corresponding input unit without modification. + // Forward the input unit unchanged. HttpResponseBodyPassThrough pass_through = 2; - // Replace the complete corresponding input unit. + // Replace the complete input unit. HttpResponseBodyTransform transform = 3; - // Stop delivery of this response. See HttpResponseBlockDelivery for the - // difference between results before and after head commitment. + // Stop delivery. See HttpResponseBlockDelivery. HttpResponseBlockDelivery block_delivery = 8; - // Finalize this unit, then opt out of inspecting the rest of the response. + // Finalize this unit and stop inspecting. HttpResponseBodySkipRemaining skip_remaining = 9; } - // Free-form service diagnostic. OpenShell never exposes this to the sandbox - // or security logs. Limited to 4 KiB before discarding. + // Service diagnostic, never sent to the sandbox or security logs. Maximum + // 4 KiB. string reason = 4; - // Optional stable machine-readable code for audit and diagnostic output. - // Codes follow HttpResponsePreflightDecision.reason_code and are never - // returned to the sandbox. + // Optional audit code using preflight reason_code format. Never sent to the + // sandbox. string reason_code = 5; - // Audit-safe findings produced for this body unit. At most 32 findings of at - // most 4 KiB encoded each are accepted. + // Up to 32 audit-safe findings, each limited to 4 KiB encoded. repeated Finding findings = 6; - // Non-secret service-defined metadata included in diagnostics. At most 64 - // entries and 32 KiB of combined key/value data are accepted. + // Non-secret diagnostic metadata, limited to 64 entries and 32 KiB. map metadata = 7; } -// HttpResponseBodyPassThrough preserves the corresponding input unit exactly. +// Preserves the input unit. message HttpResponseBodyPassThrough {} -// HttpResponseBodySkipRemaining finalizes the current unit and ends this -// stage's participation in the response. OpenShell forwards every later unit -// without sending it to this stage, other stages continue to evaluate those -// units, and this stage receives no end_of_stream unit. A stage that deferred -// bytes must emit them in the nested transform. Coverage records the remaining -// body as skipped by choice, distinct from a fail-open bypass. On the -// WHOLE_BODY_BYTES unit this is equivalent to the nested action alone. +// Finalizes this unit and ends the stage. Later units bypass it but continue +// through other stages. This stage receives no end_of_stream. A deferred tail +// must be in transform. For WHOLE_BODY_BYTES, this equals its nested action. message HttpResponseBodySkipRemaining { - // Exactly one action for the current unit is required. + // Exactly one action for the current unit. oneof current { - // Forward the current unit without modification. + // Forward the current unit unchanged. HttpResponseBodyPassThrough pass_through = 1; - // Replace the current unit, including any deferred bytes. + // Replace the current unit and any deferred bytes. HttpResponseBodyTransform transform = 2; } } -// HttpResponseBodyTransform replaces the complete corresponding input unit. +// Replaces the complete input unit. message HttpResponseBodyTransform { - // Exactly one replacement payload is required. Present empty data deletes the - // input unit. The replacement is limited to max_payload_bytes and may carry - // fewer or more bytes than the input. When - // HttpResponsePreflight.deferral_permitted is true, a stage may withhold - // trailing bytes from this replacement and emit them in a later replacement, - // holding at most half of max_payload_bytes. Otherwise every replacement - // must fully account for its own input unit. + // Required replacement, limited to max_payload_bytes. Present empty data + // deletes the input unit. When deferral_permitted, up to half the limit may be + // held for a later replacement; otherwise this must account for all input. oneof replacement { - // Normalized replacement response bytes. + // Normalized replacement bytes. bytes data = 1; } } -// Why OpenShell is ending an opened middleware stage stream. This enum is a -// stable lifecycle classification shared by every streaming middleware -// protocol. Protocol-specific information belongs in MiddlewareSessionEnd. +// Stable reason OpenShell ended a middleware stage stream. enum MiddlewareSessionEndReason { - // Invalid or unavailable terminal reason. + // Invalid reason. MIDDLEWARE_SESSION_END_REASON_UNSPECIFIED = 0; - // The evaluated interaction completed normally. + // Evaluation completed. MIDDLEWARE_SESSION_END_REASON_NORMAL = 1; - // The sandbox-side peer disconnected before processing completed. + // The sandbox peer disconnected. MIDDLEWARE_SESSION_END_REASON_DOWNSTREAM_DISCONNECT = 2; // A policy reload replaced the active middleware chain. MIDDLEWARE_SESSION_END_REASON_POLICY_RELOAD = 3; - // A middleware stage authoritatively denied the operation or blocked - // response delivery. + // A stage denied the operation or blocked the response. MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_DENIAL = 4; - // This or another selected middleware stage failed. + // A selected stage failed. MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; - // A proxied or middleware protocol contract was violated. Producers set - // protocol_error to identify the violated contract. + // A proxied or middleware protocol was violated. MIDDLEWARE_SESSION_END_REASON_PROTOCOL_ERROR = 6; - // OpenShell cancelled middleware evaluation for another lifecycle reason. + // Evaluation was canceled for another reason. MIDDLEWARE_SESSION_END_REASON_CANCELLATION = 7; - // The upstream rejected the operation or failed before producing a valid - // response head or accepted upgrade. + // Upstream rejected or failed before a valid response or upgrade. MIDDLEWARE_SESSION_END_REASON_UPSTREAM_FAILURE = 8; // Network policy denied the operation. MIDDLEWARE_SESSION_END_REASON_POLICY_DENIAL = 9; - // The middleware stage voluntarily declined inspection during preflight. - // This is a successful stage-local outcome, not a cancellation or denial. + // The stage successfully declined inspection during preflight. MIDDLEWARE_SESSION_END_REASON_STAGE_SKIPPED = 10; - // The upstream peer disconnected after producing a valid response head or - // accepted upgrade, before processing completed. + // Upstream disconnected after a valid response or upgrade. MIDDLEWARE_SESSION_END_REASON_UPSTREAM_DISCONNECT = 11; } -// MiddlewareSessionEnd is OpenShell's best-effort terminal notification for -// one opened stage stream. A stage receives at most one and does not return a -// result. The reason remains meaningful when protocol_error is absent or its -// domain is unknown to an older consumer. +// Best-effort terminal notification. A stage receives at most one and sends no +// result. message MiddlewareSessionEnd { - // Stable lifecycle classification. Producers never send UNSPECIFIED. + // Terminal reason. Producers never send UNSPECIFIED. MiddlewareSessionEndReason reason = 1; - // Structured detail set exactly when reason is PROTOCOL_ERROR. Consumers - // treat absent or unrecognized detail as a generic protocol error. + // Set only for PROTOCOL_ERROR. Missing or unknown details mean a generic + // protocol error. MiddlewareSessionProtocolError protocol_error = 2; } -// MiddlewareSessionProtocolError identifies the contract violated by a -// protocol error. OpenShell adds typed domains as it supports more protocols. +// Details for a protocol-error session end. message MiddlewareSessionProtocolError { oneof domain { - // The proxied WebSocket traffic violated the WebSocket protocol. + // WebSocket protocol violation. WebSocketProtocolError web_socket = 1; - // The middleware event/result exchange violated the OpenShell protocol. + // Middleware event/result protocol violation. MiddlewareExchangeProtocolError middleware_exchange = 2; } } -// WebSocketProtocolError identifies a WebSocket protocol violation. Stable, -// actionable subcategories may be added later. +// WebSocket protocol error details, reserved for future categories. message WebSocketProtocolError {} -// MiddlewareExchangeProtocolError identifies an invalid middleware -// event/result exchange. Stable, actionable subcategories may be added later. +// Middleware exchange error details, reserved for future categories. message MiddlewareExchangeProtocolError {} // Supervisor operation selected for middleware evaluation. From fd57519dc335cbaf133e5c219f90e20ab1d274b1 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Wed, 2 Sep 2026 14:16:45 -0700 Subject: [PATCH 12/24] fix(middleware): reject undispatched response bindings Signed-off-by: Piotr Mlocek --- .../openshell-supervisor-middleware/src/lib.rs | 16 +++++++++++----- proto/supervisor_middleware.proto | 3 ++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index 7482dd1fc7..1065c18faf 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -831,7 +831,6 @@ fn validate_payload_limit(source: &str, binding: &MiddlewareBinding) -> Result Result Ok(SupportedBinding::HttpResponsePreReturn), + ) => Err(miette!( + "{source} advertises HTTP_RESPONSE/PRE_RETURN, which is not yet supported" + )), ( Some(SupervisorMiddlewareOperation::WebsocketMessage), Some(SupervisorMiddlewarePhase::PreCredentials), @@ -3685,7 +3686,7 @@ mod tests { } #[test] - fn manifest_accepts_http_response_pre_return_binding() { + fn manifest_rejects_http_response_pre_return_binding_until_dispatch_is_available() { let registration = external_registration(4096); let manifest = MiddlewareManifest { name: "example/response".into(), @@ -3699,8 +3700,13 @@ mod tests { expected_audience: String::new(), }; - validate_external_manifest(®istration, &manifest, 4096, false) - .expect("HTTP response pre-return binding is supported"); + let error = validate_external_manifest(®istration, &manifest, 4096, false) + .expect_err("HTTP response pre-return binding must remain unavailable"); + assert!( + error + .to_string() + .contains("HTTP_RESPONSE/PRE_RETURN, which is not yet supported") + ); } #[test] diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index b864cd36c5..9ed4560271 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -64,7 +64,8 @@ message MiddlewareManifest { message MiddlewareBinding { // Supported operation. SupervisorMiddlewareOperation operation = 1; - // Supported phase. WEBSOCKET_MESSAGE/PRE_RETURN is rejected. + // Supported phase. Current manifest validation accepts PRE_CREDENTIALS only; + // PRE_RETURN remains unavailable until the matching relay paths dispatch it. SupervisorMiddlewarePhase phase = 2; // Maximum request body, WebSocket message, or response body unit/replacement. // Required for payload-bearing operations. From 590b19343e21446030512aa3ac7e964980a64e21 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Wed, 2 Sep 2026 14:23:05 -0700 Subject: [PATCH 13/24] docs(middleware): simplify phase field comment Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 9ed4560271..064ae6ad3f 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -64,8 +64,7 @@ message MiddlewareManifest { message MiddlewareBinding { // Supported operation. SupervisorMiddlewareOperation operation = 1; - // Supported phase. Current manifest validation accepts PRE_CREDENTIALS only; - // PRE_RETURN remains unavailable until the matching relay paths dispatch it. + // Supported phase. SupervisorMiddlewarePhase phase = 2; // Maximum request body, WebSocket message, or response body unit/replacement. // Required for payload-bearing operations. From 97da678982bb1000fdc6e05fda5af7907266820e Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 3 Sep 2026 14:29:06 -0700 Subject: [PATCH 14/24] refactor(middleware): rename HTTP response preflight result Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 064ae6ad3f..5e3f1c367a 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -143,7 +143,7 @@ message HttpResponseEvent { message HttpResponseEventResult { oneof result { // Result for preflight. - HttpResponsePreflightDecision preflight_decision = 1; + HttpResponsePreflightResult preflight_result = 1; // Result for the next body unit. HttpResponseBodyResult body_result = 2; } @@ -183,7 +183,7 @@ message HttpResponsePreflight { } // Selects skip, inspect, or block. Diagnostic fields apply to every action. -message HttpResponsePreflightDecision { +message HttpResponsePreflightResult { oneof action { // Deliver unchanged without invoking on_error. HttpResponsePreflightSkip skip = 1; From 66babd2326e9de79646c57e28a84bd543648b156 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 3 Sep 2026 14:39:33 -0700 Subject: [PATCH 15/24] feat(middleware): add HTTP response trailer results Signed-off-by: Piotr Mlocek --- .../src/headers.rs | 128 ++++++++++++++++++ proto/supervisor_middleware.proto | 41 +++++- 2 files changed, 166 insertions(+), 3 deletions(-) diff --git a/crates/openshell-supervisor-middleware/src/headers.rs b/crates/openshell-supervisor-middleware/src/headers.rs index 454b27c2aa..1dc37bff20 100644 --- a/crates/openshell-supervisor-middleware/src/headers.rs +++ b/crates/openshell-supervisor-middleware/src/headers.rs @@ -16,6 +16,7 @@ pub const MAX_HEADER_MUTATION_BYTES: usize = 32 * 1024; pub enum HeaderAuthority { Request, Response, + ResponseTrailers, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -30,6 +31,7 @@ pub enum HeaderMutationError { InvalidExistingAction, MissingExistingAction { name: String }, UnsupportedExistingAction, + AbsentTrailerName { name: String }, Empty, } @@ -47,6 +49,7 @@ impl HeaderMutationError { Self::InvalidExistingAction => "header_mutation_invalid_existing_action", Self::MissingExistingAction { .. } => "header_mutation_missing_existing_action", Self::UnsupportedExistingAction => "header_mutation_unsupported_existing_action", + Self::AbsentTrailerName { .. } => "trailer_mutation_absent_name", Self::Empty => "header_mutation_empty", } } @@ -104,6 +107,10 @@ impl std::fmt::Display for HeaderMutationError { "middleware returned unsupported on_existing action" ) } + Self::AbsentTrailerName { name } => write!( + formatter, + "middleware cannot create absent response trailer '{name}'" + ), Self::Empty => write!(formatter, "middleware returned an empty header mutation"), } } @@ -133,6 +140,15 @@ pub fn apply( Some(header_mutation::Operation::Write(write)) => { let name = validate_name(&write.name)?; validate_authority(authority, MutationKind::Write, &write.name, &name)?; + if authority == HeaderAuthority::ResponseTrailers + && !existing_headers + .iter() + .any(|existing| existing.name.eq_ignore_ascii_case(&name)) + { + return Err(HeaderMutationError::AbsentTrailerName { + name: write.name.clone(), + }); + } if is_connection_nominated(connection_nominated_headers, &name) { return Err(HeaderMutationError::HopByHop { name: write.name.clone(), @@ -229,6 +245,7 @@ fn validate_authority( is_response_protected(normalized_name) || (kind == MutationKind::Write && is_response_remove_only(normalized_name)) } + HeaderAuthority::ResponseTrailers => is_response_protected(normalized_name), }; if protected { return Err(HeaderMutationError::Protected { @@ -650,4 +667,115 @@ mod tests { } } } + + #[test] + fn empty_response_trailers_accept_pass_through() { + assert_eq!( + apply(HeaderAuthority::ResponseTrailers, &[], &[], &[]), + Ok(Vec::new()) + ); + } + + #[test] + fn response_trailer_pass_through_preserves_fields_and_order() { + let existing = [ + header("x-checksum", "one"), + header("x-trace", "middle"), + header("x-checksum", "two"), + ]; + + let updated = apply(HeaderAuthority::ResponseTrailers, &existing, &[], &[]) + .expect("empty mutation list"); + + assert_eq!(updated, existing); + } + + #[test] + fn response_trailer_mutations_modify_remove_and_preserve_order() { + let existing = [ + header("x-checksum", "one"), + header("x-remove", "gone"), + header("x-trace", "middle"), + header("x-checksum", "two"), + ]; + + let updated = apply( + HeaderAuthority::ResponseTrailers, + &existing, + &[], + &[ + write("X-Checksum", "replacement", ExistingHeaderAction::Overwrite), + remove("X-Remove"), + write("X-Trace", "last", ExistingHeaderAction::Append), + ], + ) + .expect("permitted response trailer mutations"); + + assert_eq!( + updated, + vec![ + header("x-trace", "middle"), + header("x-checksum", "replacement"), + header("x-trace", "last"), + ] + ); + } + + #[test] + fn response_trailer_write_cannot_introduce_an_absent_name() { + let existing = [header("x-checksum", "one")]; + let error = apply( + HeaderAuthority::ResponseTrailers, + &existing, + &[], + &[write( + "X-New-Trailer", + "value", + ExistingHeaderAction::Overwrite, + )], + ) + .expect_err("absent response trailer name"); + + assert_eq!( + error, + HeaderMutationError::AbsentTrailerName { + name: "X-New-Trailer".into() + } + ); + } + + #[test] + fn response_trailer_removal_of_an_absent_name_is_a_noop() { + let existing = [header("x-checksum", "one")]; + let updated = apply( + HeaderAuthority::ResponseTrailers, + &existing, + &[], + &[remove("X-Missing")], + ) + .expect("absent response trailer removal"); + + assert_eq!(updated, existing); + } + + #[test] + fn response_trailer_protected_fields_cannot_be_mutated() { + for mutation in [ + write("Content-Length", "10", ExistingHeaderAction::Overwrite), + remove("Set-Cookie"), + ] { + let error = apply( + HeaderAuthority::ResponseTrailers, + &[ + header("content-length", "5"), + header("set-cookie", "session=upstream"), + ], + &[], + &[mutation], + ) + .expect_err("protected response trailer mutation"); + + assert!(matches!(error, HeaderMutationError::Protected { .. })); + } + } } diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 5e3f1c367a..5b4f316fe5 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -126,26 +126,30 @@ message HttpHeader { } // One ordered response event. A stream starts with preflight, may continue with -// body units, and may end with one best-effort session_end. +// body units and trailers, and may end with one best-effort session_end. message HttpResponseEvent { oneof event { // Initial response head and request context. HttpResponsePreflight preflight = 1; // Next normalized body unit. HttpResponseBodyUnit body = 2; + // Normalized trailers after the final body result. + HttpResponseTrailers trailers = 4; // Optional terminal notification. MiddlewareSessionEnd session_end = 3; } } -// Each preflight and body event requires one ordered result. session_end has no -// result. +// Each preflight, body, and trailers event requires one ordered result. +// session_end has no result. message HttpResponseEventResult { oneof result { // Result for preflight. HttpResponsePreflightResult preflight_result = 1; // Result for the next body unit. HttpResponseBodyResult body_result = 2; + // Result for response trailers. + HttpResponseTrailersResult trailers_result = 3; } } @@ -316,6 +320,37 @@ message HttpResponseBodyTransform { } } +// The current normalized response trailers in wire order. Repeated names stay +// as separate fields. A stage that completes WHOLE_BODY_BYTES or STREAM_BYTES +// receives exactly one trailers event after its final body result, including +// when this set is empty. SKIP, HEADERS_ONLY, semantically bodyless responses, +// and stages ended by block, failure, or skip_remaining receive no trailers. +message HttpResponseTrailers { + repeated HttpHeader headers = 1; +} + +// Applies ordered trailer mutations atomically. An empty mutation list +// preserves the current trailers. A write may target only a case-insensitive +// name present in the trailers event; V1 cannot create a trailer name. Removal +// of an absent name is a no-op. Credential, routing, framing, coding, range, +// hop-by-hop, and connection-nominated fields are protected. A violating result +// is a middleware failure handled according to on_error. +message HttpResponseTrailersResult { + // At most 64 operations, 32 KiB of validated name/value data, and 64 KiB + // encoded are accepted. + repeated HeaderMutation trailer_mutations = 1; + // Service diagnostic, never sent to the sandbox or security logs. Maximum + // 4 KiB. + string reason = 2; + // Optional audit code using preflight reason_code format. Never sent to the + // sandbox. + string reason_code = 3; + // Up to 32 audit-safe findings, each limited to 4 KiB encoded. + repeated Finding findings = 4; + // Non-secret diagnostic metadata, limited to 64 entries and 32 KiB. + map metadata = 5; +} + // Stable reason OpenShell ended a middleware stage stream. enum MiddlewareSessionEndReason { // Invalid reason. From 02f39843623b46601ad6cfe7abde726f0a4476c2 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 3 Sep 2026 14:43:30 -0700 Subject: [PATCH 16/24] docs(middleware): define response block delivery Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 5b4f316fe5..4d1a5eed71 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -211,11 +211,27 @@ message HttpResponsePreflightResult { // Ends this stage successfully without body inspection. message HttpResponsePreflightSkip {} -// Blocks delivery as a successful decision regardless of on_error. Preflight -// and WHOLE_BODY_BYTES blocks replace the uncommitted response with a platform -// error. STREAM_BYTES blocks abort delivery after any sent prefix. The upstream -// request is not undone. A block wins over other actions and failures, stops -// later evaluation, and ends opened stages with MIDDLEWARE_DENIAL. +// Blocks delivery as a successful decision regardless of on_error. OpenShell +// evaluates results in policy order. Once it accepts a valid block, it stops +// later middleware evaluation and ends every still-writable opened stage with +// MIDDLEWARE_DENIAL. A failure handled earlier may already have stopped +// evaluation, so a later block does not override it. An invalid block result +// is a middleware failure handled according to on_error. The upstream request +// has already run; blocking its response does not reject or roll back that +// request. +// +// Before response commitment, including at preflight and during +// WHOLE_BODY_BYTES, OpenShell replaces 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 the +// result supplies one. OpenShell never returns the free-form reason or writes it +// to security logs. For HEAD, OpenShell sends the canonical response headers +// and Content-Length but no body. It closes the downstream connection after the +// denial response. +// +// After response commitment, including during STREAM_BYTES, OpenShell aborts +// downstream delivery. It does not inject an error body, a terminating chunk, +// or an error trailer. OpenShell does not reuse the upstream connection. message HttpResponseBlockDelivery {} // Selects body inspection and response-header mutations. @@ -284,8 +300,9 @@ message HttpResponseBodyResult { // Service diagnostic, never sent to the sandbox or security logs. Maximum // 4 KiB. string reason = 4; - // Optional audit code using preflight reason_code format. Never sent to the - // sandbox. + // Optional audit code using preflight reason_code format. When OpenShell + // accepts block_delivery before response commitment, it includes this code + // in the canonical denial response. It is never returned after commitment. string reason_code = 5; // Up to 32 audit-safe findings, each limited to 4 KiB encoded. repeated Finding findings = 6; From b28fd3ea279f2efc2217926f1c7dee6df29b7bee Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 3 Sep 2026 14:49:25 -0700 Subject: [PATCH 17/24] docs(middleware): define stage-local response body modes Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 4d1a5eed71..6aa2ad58c2 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -176,10 +176,16 @@ message HttpResponsePreflight { // whole-body input/replacement and each stream replacement. Stream inputs use // at most half this limit. uint64 max_payload_bytes = 7; - // Modes computed once from the original response head. HEADERS_ONLY is always - // present and is the only mode for bodyless, partial, encoded, or no-transform - // responses. Oversized or open-ended responses omit WHOLE_BODY_BYTES. - // Selecting an unlisted mode fails according to on_error. + // Modes derived independently for this stage. OpenShell first determines + // response-shape eligibility from the original final response head, then + // applies this stage's effective max_payload_bytes. Different stages may + // receive different lists. HEADERS_ONLY is always present and is the only + // mode for bodyless, partial, encoded, or no-transform responses. For an + // otherwise eligible response, a known body larger than this stage's limit + // omits WHOLE_BODY_BYTES. An eligible unknown-length response may select + // WHOLE_BODY_BYTES and later fail with whole_body_over_capacity according to + // this stage's on_error. Selecting an unlisted mode fails according to + // on_error. repeated HttpResponseBodyMode permitted_body_modes = 8; // Allows STREAM_BYTES replacements to defer bytes across units. Set only for // fail-closed stages; fail-open stages cannot defer. From 49a8b7762f16e46d510dd049d93543130722f610 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 3 Sep 2026 14:53:42 -0700 Subject: [PATCH 18/24] docs(middleware): define final response body units Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 6aa2ad58c2..0a79836c27 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -36,8 +36,9 @@ service SupervisorMiddleware { // HttpResponsePreReturn evaluates one response for one middleware stage before // OpenShell returns it to the sandbox. service HttpResponsePreReturn { - // Evaluate starts with preflight, followed by selected body units. It may end - // with one best-effort session_end. + // Evaluate starts with preflight and may continue with selected body units + // and trailers. A body unit marked end_of_stream ends body inspection, not + // the event stream. Trailers and one best-effort session_end may follow. rpc Evaluate(stream HttpResponseEvent) returns (stream HttpResponseEventResult); } @@ -126,7 +127,8 @@ message HttpHeader { } // One ordered response event. A stream starts with preflight, may continue with -// body units and trailers, and may end with one best-effort session_end. +// body units ending in end_of_stream, may then include trailers, and may end +// with one best-effort session_end. message HttpResponseEvent { oneof event { // Initial response head and request context. @@ -274,16 +276,18 @@ message HttpResponseBodyUnit { // Contiguous and stage-local, starting at 1. uint64 sequence = 1; oneof payload { - // Bytes without transfer framing. WHOLE_BODY_BYTES represents an empty body - // with present empty data. STREAM_BYTES units are at most the smaller of - // 64 KiB and half max_payload_bytes, and may be shorter to preserve flushing. + // Bytes without transfer framing. A body-capable response with no body bytes + // has present empty data in sequence 1. STREAM_BYTES units are at most the + // smaller of 64 KiB and half max_payload_bytes, and may be shorter to + // preserve flushing. bytes data = 2; } - // Marks the final unit. A completed inspection receives exactly one, including - // an empty sequence-1 unit for an empty body. OpenShell does not read ahead, - // so it may send an empty final unit after the last data unit. The final unit - // requires a result containing any deferred bytes. Interrupted streams may - // end with session_end instead. + // Marks the final body unit. Every normally completed body inspection receives + // exactly one. For a body-capable response with no body bytes, this is the + // empty sequence-1 unit. OpenShell does not read ahead, so it may send an empty + // final unit after the last nonempty unit. The matching result must flush all + // deferred bytes. Trailers and session_end may follow. A stage ended by + // skip_remaining, block, or failure receives no later final unit. bool end_of_stream = 3; } From 4e4ed546aa816539faa9bc5159bcd3c4fd4837f6 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 3 Sep 2026 14:58:57 -0700 Subject: [PATCH 19/24] docs(middleware): define streaming response deferral Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 44 ++++++++++++++++++------------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 0a79836c27..224d9715fc 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -176,7 +176,7 @@ message HttpResponsePreflight { google.protobuf.Struct config = 6; // Effective minimum of platform, registration, and binding limits. Applies to // whole-body input/replacement and each stream replacement. Stream inputs use - // at most half this limit. + // at most floor(max_payload_bytes / 2). uint64 max_payload_bytes = 7; // Modes derived independently for this stage. OpenShell first determines // response-shape eligibility from the original final response head, then @@ -186,11 +186,13 @@ message HttpResponsePreflight { // otherwise eligible response, a known body larger than this stage's limit // omits WHOLE_BODY_BYTES. An eligible unknown-length response may select // WHOLE_BODY_BYTES and later fail with whole_body_over_capacity according to - // this stage's on_error. Selecting an unlisted mode fails according to - // on_error. + // this stage's on_error. STREAM_BYTES is omitted when + // floor(max_payload_bytes / 2) is zero. Selecting an unlisted mode fails + // according to on_error. repeated HttpResponseBodyMode permitted_body_modes = 8; - // Allows STREAM_BYTES replacements to defer bytes across units. Set only for - // fail-closed stages; fail-open stages cannot defer. + // Allows a STREAM_BYTES stage to retain input bytes for a later replacement. + // True only for fail-closed stages. Fail-open stages must account for every + // input unit in their results and cannot retain bytes across units. bool deferral_permitted = 9; } @@ -264,9 +266,10 @@ enum HttpResponseBodyMode { // Input and replacement must fit max_payload_bytes. Capacity and deadline // failures use whole_body_over_capacity and whole_body_accumulation_timeout. HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES = 2; - // Receive normalized units ending with end_of_stream. Each replacement must - // fit max_payload_bytes. The full body may exceed it and has no accumulation - // deadline. + // Receive normalized units ending with end_of_stream. Each input is at most + // min(64 KiB, floor(max_payload_bytes / 2)), and each replacement must fit + // max_payload_bytes. The full body may exceed the limit and has no + // accumulation deadline. HTTP_RESPONSE_BODY_MODE_STREAM_BYTES = 3; } @@ -277,8 +280,8 @@ message HttpResponseBodyUnit { uint64 sequence = 1; oneof payload { // Bytes without transfer framing. A body-capable response with no body bytes - // has present empty data in sequence 1. STREAM_BYTES units are at most the - // smaller of 64 KiB and half max_payload_bytes, and may be shorter to + // has present empty data in sequence 1. STREAM_BYTES input size is at most + // min(64 KiB, floor(max_payload_bytes / 2)). A unit may be shorter to // preserve flushing. bytes data = 2; } @@ -286,13 +289,15 @@ message HttpResponseBodyUnit { // exactly one. For a body-capable response with no body bytes, this is the // empty sequence-1 unit. OpenShell does not read ahead, so it may send an empty // final unit after the last nonempty unit. The matching result must flush all - // deferred bytes. Trailers and session_end may follow. A stage ended by + // retained bytes. Trailers and session_end may follow. A stage ended by // skip_remaining, block, or failure receives no later final unit. bool end_of_stream = 3; } // Result for one body unit. Units are processed in lockstep; V1 does not -// support ownership transfer. +// support ownership transfer. OpenShell enforces input and replacement size +// bounds and validates the event lifecycle. It cannot distinguish bytes +// intentionally deleted from bytes retained privately by middleware. message HttpResponseBodyResult { // Must match the next unit. Zero, gaps, duplicates, and regressions fail. uint64 sequence = 1; @@ -323,15 +328,16 @@ message HttpResponseBodyResult { // Preserves the input unit. message HttpResponseBodyPassThrough {} -// Finalizes this unit and ends the stage. Later units bypass it but continue -// through other stages. This stage receives no end_of_stream. A deferred tail -// must be in transform. For WHOLE_BODY_BYTES, this equals its nested action. +// Finalizes this unit and ends the stage. This stage receives no later body or +// trailer events. The current and later units continue through other stages. +// Any retained bytes must be in transform. For WHOLE_BODY_BYTES, this equals +// its nested action. message HttpResponseBodySkipRemaining { // Exactly one action for the current unit. oneof current { // Forward the current unit unchanged. HttpResponseBodyPassThrough pass_through = 1; - // Replace the current unit and any deferred bytes. + // Replace the current unit and flush all retained bytes. HttpResponseBodyTransform transform = 2; } } @@ -339,8 +345,10 @@ message HttpResponseBodySkipRemaining { // Replaces the complete input unit. message HttpResponseBodyTransform { // Required replacement, limited to max_payload_bytes. Present empty data - // deletes the input unit. When deferral_permitted, up to half the limit may be - // held for a later replacement; otherwise this must account for all input. + // deletes the input unit. When deferral_permitted, middleware may retain at + // most floor(max_payload_bytes / 2) input bytes for a later replacement. + // Otherwise it must not retain input across units. The transform for a normal + // final unit or skip_remaining.transform must flush every retained byte. oneof replacement { // Normalized replacement bytes. bytes data = 1; From 44e1787a55ec3079f9ef571823240b9aa67a6ff1 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 3 Sep 2026 15:05:00 -0700 Subject: [PATCH 20/24] docs(middleware): defer whole-body accumulation timeout Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 224d9715fc..b478dd1a08 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -263,13 +263,13 @@ enum HttpResponseBodyMode { // Inspect only the response head. HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY = 1; // Buffer the normalized body as one final unit before committing the head. - // Input and replacement must fit max_payload_bytes. Capacity and deadline - // failures use whole_body_over_capacity and whole_body_accumulation_timeout. + // Input and replacement must fit max_payload_bytes. Capacity failures use + // whole_body_over_capacity and follow this stage's on_error. HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES = 2; // Receive normalized units ending with end_of_stream. Each input is at most // min(64 KiB, floor(max_payload_bytes / 2)), and each replacement must fit - // max_payload_bytes. The full body may exceed the limit and has no - // accumulation deadline. + // max_payload_bytes. The full body may exceed the limit. STREAM_BYTES has no + // total response-lifetime deadline. HTTP_RESPONSE_BODY_MODE_STREAM_BYTES = 3; } From 60e28b7c4ae8ab51568043266f9bf92217d26729 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 3 Sep 2026 15:24:02 -0700 Subject: [PATCH 21/24] docs(middleware): align response result diagnostics Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index b478dd1a08..2c68c8d1c5 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -197,6 +197,8 @@ message HttpResponsePreflight { } // Selects skip, inspect, or block. Diagnostic fields apply to every action. +// Invalid diagnostics make the entire result a middleware failure handled +// according to on_error. message HttpResponsePreflightResult { oneof action { // Deliver unchanged without invoking on_error. @@ -209,8 +211,8 @@ message HttpResponsePreflightResult { // Service diagnostic, never sent to the sandbox or security logs. Maximum // 4 KiB. string reason = 3; - // Optional audit code using HttpRequestResult.reason_code format. Returned to - // the sandbox only for block_delivery. + // Optional audit code using the HttpRequestResult.reason_code format and + // 64-byte maximum. Returned to the sandbox only for block_delivery. string reason_code = 4; // Up to 32 audit-safe findings, each limited to 4 KiB encoded. repeated Finding findings = 5; @@ -295,9 +297,11 @@ message HttpResponseBodyUnit { } // Result for one body unit. Units are processed in lockstep; V1 does not -// support ownership transfer. OpenShell enforces input and replacement size -// bounds and validates the event lifecycle. It cannot distinguish bytes -// intentionally deleted from bytes retained privately by middleware. +// support ownership transfer. Diagnostic fields apply to every action. Invalid +// diagnostics make the entire result a middleware failure handled according to +// on_error. OpenShell enforces input and replacement size bounds and validates +// the event lifecycle. It cannot distinguish bytes intentionally deleted from +// bytes retained privately by middleware. message HttpResponseBodyResult { // Must match the next unit. Zero, gaps, duplicates, and regressions fail. uint64 sequence = 1; @@ -315,9 +319,10 @@ message HttpResponseBodyResult { // Service diagnostic, never sent to the sandbox or security logs. Maximum // 4 KiB. string reason = 4; - // Optional audit code using preflight reason_code format. When OpenShell - // accepts block_delivery before response commitment, it includes this code - // in the canonical denial response. It is never returned after commitment. + // Optional audit code using the HttpRequestResult.reason_code format and + // 64-byte maximum. When OpenShell accepts block_delivery before response + // commitment, it includes this code in the canonical denial response. It is + // never returned after commitment. string reason_code = 5; // Up to 32 audit-safe findings, each limited to 4 KiB encoded. repeated Finding findings = 6; @@ -368,8 +373,10 @@ message HttpResponseTrailers { // preserves the current trailers. A write may target only a case-insensitive // name present in the trailers event; V1 cannot create a trailer name. Removal // of an absent name is a no-op. Credential, routing, framing, coding, range, -// hop-by-hop, and connection-nominated fields are protected. A violating result -// is a middleware failure handled according to on_error. +// hop-by-hop, and connection-nominated fields are protected. Diagnostic fields +// apply whether mutations are empty or nonempty. Invalid diagnostics or +// mutations make the entire result a middleware failure handled according to +// on_error. message HttpResponseTrailersResult { // At most 64 operations, 32 KiB of validated name/value data, and 64 KiB // encoded are accepted. @@ -377,8 +384,8 @@ message HttpResponseTrailersResult { // Service diagnostic, never sent to the sandbox or security logs. Maximum // 4 KiB. string reason = 2; - // Optional audit code using preflight reason_code format. Never sent to the - // sandbox. + // Optional audit code using the HttpRequestResult.reason_code format and + // 64-byte maximum. Never sent to the sandbox. string reason_code = 3; // Up to 32 audit-safe findings, each limited to 4 KiB encoded. repeated Finding findings = 4; From c8fc5dd28cebf3d9793183402fba9914bb684771 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 3 Sep 2026 21:05:08 -0700 Subject: [PATCH 22/24] test(middleware): cover upstream WebSocket disconnect Signed-off-by: Piotr Mlocek --- .../src/l7/websocket.rs | 66 +++++++++++++++++++ proto/supervisor_middleware.proto | 4 +- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index b7d5c67dbd..febec7000b 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -4071,6 +4071,72 @@ network_policies: .expect("middleware server"); } + #[tokio::test] + async fn upstream_eof_reports_one_upstream_disconnect_while_downstream_is_open() { + let (mut session, mut observed, shutdown_tx, server_task) = + recording_middleware_session("wss").await; + assert!(session.start("").await.allowed); + assert!(matches!( + tokio::time::timeout(std::time::Duration::from_secs(2), observed.recv()) + .await + .expect("middleware observes session start"), + Some(ObservedWebSocketRequest::SessionStart) + )); + + let (client_app, mut relay_client) = tokio::io::duplex(4096); + let (mut relay_upstream, upstream_app) = tokio::io::duplex(4096); + let relay = tokio::spawn(async move { + relay_with_options( + &mut relay_client, + &mut relay_upstream, + Vec::new(), + "api.openai.com", + 443, + RelayOptions { + policy_name: "rest-api", + assembly_budget: WebSocketAssemblyBudget::default(), + resolver: None, + generation_guard: None, + provider_credentials: None, + target: "/", + inspector: None, + compression: WebSocketCompression::None, + middleware_session: Some(session), + middleware_context: None, + deny_uninspected_credentials: false, + }, + ) + .await + }); + + drop(upstream_app); + tokio::time::timeout(std::time::Duration::from_secs(2), relay) + .await + .expect("relay finishes after upstream disconnect") + .expect("join relay") + .expect("upstream EOF ends relay normally"); + assert!(matches!( + tokio::time::timeout(std::time::Duration::from_secs(2), observed.recv()) + .await + .expect("middleware observes session end"), + Some(ObservedWebSocketRequest::SessionEnd( + openshell_core::proto::MiddlewareSessionEndReason::UpstreamDisconnect, + )) + )); + assert!( + observed.try_recv().is_err(), + "upstream EOF must produce exactly one session end" + ); + + drop(client_app); + let _ = shutdown_tx.send(()); + tokio::time::timeout(std::time::Duration::from_secs(2), server_task) + .await + .expect("middleware server shuts down") + .expect("join middleware server") + .expect("middleware server"); + } + #[tokio::test] async fn denied_websocket_session_start_reports_middleware_failure_before_close() { let (session, mut observed, shutdown_tx, server_task) = diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 2c68c8d1c5..61b3bb9625 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -399,7 +399,9 @@ enum MiddlewareSessionEndReason { MIDDLEWARE_SESSION_END_REASON_UNSPECIFIED = 0; // Evaluation completed. MIDDLEWARE_SESSION_END_REASON_NORMAL = 1; - // The sandbox peer disconnected. + // The sandbox peer disconnected. Before the directional split, wire value 2 + // represented either peer disconnect. Mixed deployments across that + // pre-0.1.0 change are unsupported. MIDDLEWARE_SESSION_END_REASON_DOWNSTREAM_DISCONNECT = 2; // A policy reload replaced the active middleware chain. MIDDLEWARE_SESSION_END_REASON_POLICY_RELOAD = 3; From e53ff1660df61a6c2a6e6f403a044861655c27dc Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 3 Sep 2026 21:32:22 -0700 Subject: [PATCH 23/24] fix(middleware): keep response streams unit-local Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 50 ++++++++++++++----------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 61b3bb9625..4cdb7f41e1 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -175,8 +175,8 @@ message HttpResponsePreflight { // Validated service configuration. Limited to 64 KiB encoded. google.protobuf.Struct config = 6; // Effective minimum of platform, registration, and binding limits. Applies to - // whole-body input/replacement and each stream replacement. Stream inputs use - // at most floor(max_payload_bytes / 2). + // whole-body input/replacement and each stream input/replacement. Stream + // inputs use at most min(64 KiB, max_payload_bytes). uint64 max_payload_bytes = 7; // Modes derived independently for this stage. OpenShell first determines // response-shape eligibility from the original final response head, then @@ -187,13 +187,9 @@ message HttpResponsePreflight { // omits WHOLE_BODY_BYTES. An eligible unknown-length response may select // WHOLE_BODY_BYTES and later fail with whole_body_over_capacity according to // this stage's on_error. STREAM_BYTES is omitted when - // floor(max_payload_bytes / 2) is zero. Selecting an unlisted mode fails - // according to on_error. + // max_payload_bytes is zero. Selecting an unlisted mode fails according to + // on_error. repeated HttpResponseBodyMode permitted_body_modes = 8; - // Allows a STREAM_BYTES stage to retain input bytes for a later replacement. - // True only for fail-closed stages. Fail-open stages must account for every - // input unit in their results and cannot retain bytes across units. - bool deferral_permitted = 9; } // Selects skip, inspect, or block. Diagnostic fields apply to every action. @@ -269,9 +265,10 @@ enum HttpResponseBodyMode { // whole_body_over_capacity and follow this stage's on_error. HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES = 2; // Receive normalized units ending with end_of_stream. Each input is at most - // min(64 KiB, floor(max_payload_bytes / 2)), and each replacement must fit - // max_payload_bytes. The full body may exceed the limit. STREAM_BYTES has no - // total response-lifetime deadline. + // min(64 KiB, max_payload_bytes), and each replacement must fit + // max_payload_bytes. Each result fully accounts for its input unit; V1 does + // not permit retaining input across units. The full body may exceed the + // limit. STREAM_BYTES has no total response-lifetime deadline. HTTP_RESPONSE_BODY_MODE_STREAM_BYTES = 3; } @@ -283,25 +280,25 @@ message HttpResponseBodyUnit { oneof payload { // Bytes without transfer framing. A body-capable response with no body bytes // has present empty data in sequence 1. STREAM_BYTES input size is at most - // min(64 KiB, floor(max_payload_bytes / 2)). A unit may be shorter to - // preserve flushing. + // min(64 KiB, max_payload_bytes). A unit may be shorter to preserve + // flushing. bytes data = 2; } // Marks the final body unit. Every normally completed body inspection receives // exactly one. For a body-capable response with no body bytes, this is the // empty sequence-1 unit. OpenShell does not read ahead, so it may send an empty - // final unit after the last nonempty unit. The matching result must flush all - // retained bytes. Trailers and session_end may follow. A stage ended by - // skip_remaining, block, or failure receives no later final unit. + // final unit after the last nonempty unit. Trailers and session_end may + // follow. A stage ended by skip_remaining, block, or failure receives no + // later final unit. bool end_of_stream = 3; } // Result for one body unit. Units are processed in lockstep; V1 does not -// support ownership transfer. Diagnostic fields apply to every action. Invalid -// diagnostics make the entire result a middleware failure handled according to -// on_error. OpenShell enforces input and replacement size bounds and validates -// the event lifecycle. It cannot distinguish bytes intentionally deleted from -// bytes retained privately by middleware. +// support ownership transfer or cross-unit retention. Diagnostic fields apply +// to every action. Invalid diagnostics make the entire result a middleware +// failure handled according to on_error. OpenShell retains the current input +// until it validates the result, so fail-open can continue from the last input +// OpenShell still owns. message HttpResponseBodyResult { // Must match the next unit. Zero, gaps, duplicates, and regressions fail. uint64 sequence = 1; @@ -335,14 +332,13 @@ message HttpResponseBodyPassThrough {} // Finalizes this unit and ends the stage. This stage receives no later body or // trailer events. The current and later units continue through other stages. -// Any retained bytes must be in transform. For WHOLE_BODY_BYTES, this equals -// its nested action. +// For WHOLE_BODY_BYTES, this equals its nested action. message HttpResponseBodySkipRemaining { // Exactly one action for the current unit. oneof current { // Forward the current unit unchanged. HttpResponseBodyPassThrough pass_through = 1; - // Replace the current unit and flush all retained bytes. + // Replace the current unit. HttpResponseBodyTransform transform = 2; } } @@ -350,10 +346,8 @@ message HttpResponseBodySkipRemaining { // Replaces the complete input unit. message HttpResponseBodyTransform { // Required replacement, limited to max_payload_bytes. Present empty data - // deletes the input unit. When deferral_permitted, middleware may retain at - // most floor(max_payload_bytes / 2) input bytes for a later replacement. - // Otherwise it must not retain input across units. The transform for a normal - // final unit or skip_remaining.transform must flush every retained byte. + // deletes the input unit. The replacement fully accounts for this input unit; + // middleware must not retain input bytes for a later unit in V1. oneof replacement { // Normalized replacement bytes. bytes data = 1; From c9162bdd75ef96c76af2b543d72793403bb87018 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 3 Sep 2026 21:57:30 -0700 Subject: [PATCH 24/24] docs(middleware): trim disconnect compatibility note Signed-off-by: Piotr Mlocek --- proto/supervisor_middleware.proto | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 4cdb7f41e1..7e345e68ea 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -393,9 +393,7 @@ enum MiddlewareSessionEndReason { MIDDLEWARE_SESSION_END_REASON_UNSPECIFIED = 0; // Evaluation completed. MIDDLEWARE_SESSION_END_REASON_NORMAL = 1; - // The sandbox peer disconnected. Before the directional split, wire value 2 - // represented either peer disconnect. Mixed deployments across that - // pre-0.1.0 change are unsupported. + // The sandbox peer disconnected. MIDDLEWARE_SESSION_END_REASON_DOWNSTREAM_DISCONNECT = 2; // A policy reload replaced the active middleware chain. MIDDLEWARE_SESSION_END_REASON_POLICY_RELOAD = 3;