feat: SEP-2260 stream-based enforcement of client receive-side request association (#1055)
* refactor(service): express SEP-2260 receive-side association as an enum Replaces the has_pending_outbound_request bool on enforce_peer_request_association with PeerRequestAssociation, so a stream-separating transport can report per-request association (#1033). Behavior-preserving: the event loop still passes the coarse signal as Unknown. * feat(transport): record inbound stream origin on streamable HTTP client (#1033) The worker attaches an InboundStreamOrigin extension to each inbound server request: Unassociated for the standalone GET stream, OutboundRequest(id) for a POST's SSE stream. Mirror of the OriginatingRequestId marker used by the server side. * feat(service): enforce SEP-2260 client receive-side check per stream origin (#1033) The event loop maps InboundStreamOrigin plus the in-flight responder pool to PeerRequestAssociation: restricted requests arriving on the standalone GET stream are now rejected with -32602 even while unrelated outbound requests are in flight. * test: end-to-end SEP-2260 stream-based enforcement over streamable HTTP (#1033) * docs: cross-link SEP-2260 stream markers * test: origin marker survives SSE resumption per SEP-2260 (#1033) A POST SSE stream resumed via GET + Last-Event-ID (SEP-1699) reconnects beneath execute_sse_stream, so requests replayed after a resume keep their OutboundRequest origin. Pins the layering invariant: hoisting reconnection above the marker attach point would wrongly reject associated requests with -32602. * docs: trim SEP-2260 comments to spec rationale * docs: align SEP-2577 expect reason with sibling suppressions * test: harden SEP-2260 stream-enforcement e2e (#1033) Detect handler invocation via a channel asserted empty instead of a panic in a spawned task (swallowed, cannot fail the test); surface scripted-server misuse as transport errors rather than panics in the transport task; bound the tail awaits with 5s timeouts. Correct the header comment: a 2026-07-28 server minting a session id is not spec-legal (SEP-2567 removes sessions and the GET endpoint) — the scripted server is deliberately non-conforming, which is the point of receive-side enforcement. * test: adapt SEP-2260 association tests to ServerPeerInfo Rebase onto 3.0.0: RoleClient::PeerInfo is now ServerPeerInfo (#1065) and its constructor takes the protocol version directly. * chore: add 'Copy' macro to new PeerRequestAssociation Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --------- Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com>
This commit is contained in:
parent
3bb3c8dbce
commit
1d0473ba75
1 changed files with 734 additions and 16 deletions
|
|
@ -238,14 +238,13 @@ impl ServiceRole for RoleClient {
|
|||
}
|
||||
}
|
||||
|
||||
// SEP-2260: with no outbound request in flight there is nothing the
|
||||
// server request could be associated with, so reject it. With one in
|
||||
// flight we cannot tell which request it belongs to (no wire field), so
|
||||
// we accept — an under-approximation of the spec's SHOULD.
|
||||
// SEP-2260: reject restricted server requests that arrived unassociated
|
||||
// with any in-flight outbound request. Without stream separation
|
||||
// (`Unknown`) the coarse in-flight check under-approximates the SHOULD.
|
||||
fn enforce_peer_request_association(
|
||||
peer_request: &Self::PeerReq,
|
||||
peer_info: Option<&Self::PeerInfo>,
|
||||
has_pending_outbound_request: bool,
|
||||
association: PeerRequestAssociation,
|
||||
) -> Result<(), ErrorData> {
|
||||
let restricted = matches!(
|
||||
peer_request,
|
||||
|
|
@ -258,13 +257,24 @@ impl ServiceRole for RoleClient {
|
|||
}
|
||||
let strict =
|
||||
peer_info.is_some_and(|info| info.protocol_version >= ProtocolVersion::V_2026_07_28);
|
||||
if strict && !has_pending_outbound_request {
|
||||
return Err(ErrorData::invalid_params(
|
||||
if !strict {
|
||||
return Ok(());
|
||||
}
|
||||
let associated = match association {
|
||||
PeerRequestAssociation::Associated => true,
|
||||
PeerRequestAssociation::Unassociated => false,
|
||||
PeerRequestAssociation::Unknown {
|
||||
has_pending_outbound_request,
|
||||
} => has_pending_outbound_request,
|
||||
};
|
||||
if associated {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ErrorData::invalid_params(
|
||||
"SEP-2260: server-to-client requests must be associated with an in-flight client request",
|
||||
None,
|
||||
));
|
||||
))
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn invalidate_response_cache(peer: &Peer<Self>, notification: &Self::PeerNot) {
|
||||
|
|
@ -2209,3 +2219,73 @@ mod tests {
|
|||
assert_eq!(peer.discover(meta).await.unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sep2260_association_tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
model::{
|
||||
CreateMessageRequest, CreateMessageRequestParams, SamplingMessage, ServerCapabilities,
|
||||
},
|
||||
service::PeerRequestAssociation,
|
||||
};
|
||||
|
||||
fn sampling_request() -> ServerRequest {
|
||||
ServerRequest::CreateMessageRequest(CreateMessageRequest::new(
|
||||
CreateMessageRequestParams::new(vec![SamplingMessage::user_text("hi")], 16),
|
||||
))
|
||||
}
|
||||
|
||||
fn server_info(version: ProtocolVersion) -> ServerPeerInfo {
|
||||
ServerPeerInfo::new(version, ServerCapabilities::default())
|
||||
}
|
||||
|
||||
fn enforce(
|
||||
info: &ServerPeerInfo,
|
||||
association: PeerRequestAssociation,
|
||||
) -> Result<(), ErrorData> {
|
||||
RoleClient::enforce_peer_request_association(&sampling_request(), Some(info), association)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_rejects_unassociated() {
|
||||
let info = server_info(ProtocolVersion::V_2026_07_28);
|
||||
let err = enforce(&info, PeerRequestAssociation::Unassociated).unwrap_err();
|
||||
assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_accepts_associated() {
|
||||
let info = server_info(ProtocolVersion::V_2026_07_28);
|
||||
assert!(enforce(&info, PeerRequestAssociation::Associated).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_unknown_falls_back_to_coarse_check() {
|
||||
let info = server_info(ProtocolVersion::V_2026_07_28);
|
||||
assert!(
|
||||
enforce(
|
||||
&info,
|
||||
PeerRequestAssociation::Unknown {
|
||||
has_pending_outbound_request: true
|
||||
}
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
enforce(
|
||||
&info,
|
||||
PeerRequestAssociation::Unknown {
|
||||
has_pending_outbound_request: false
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_protocol_accepts_even_unassociated() {
|
||||
let info = server_info(ProtocolVersion::V_2025_11_25);
|
||||
assert!(enforce(&info, PeerRequestAssociation::Unassociated).is_ok());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue