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:
camillelawrence 2026-07-29 19:59:47 -04:00 • committed by GitHub
commit 1d0473ba75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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());
}
}