Skip to main content

winx_code_agent/
http_server.rs

1//! Optional remote MCP transport over Streamable HTTP.
2//!
3//! The endpoint exposes shell and filesystem capabilities, so remote mode is
4//! deliberately fail-closed: loopback bind by default, strong file-backed or
5//! explicit bearer credentials, bounded request concurrency/rate, and isolated
6//! daemon namespaces for independently authenticated principals.
7
8#![allow(clippy::doc_markdown)]
9
10use std::collections::{HashMap, HashSet, VecDeque};
11use std::net::{IpAddr, SocketAddr};
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use axum::{
17    extract::{ConnectInfo, Request, State},
18    http::{header, HeaderValue, StatusCode},
19    middleware::{self, Next},
20    response::{IntoResponse, Response},
21    routing::get,
22    Json, Router,
23};
24use rand::RngExt as _;
25use rmcp::transport::streamable_http_server::{
26    session::local::LocalSessionManager, session::SessionManager as _, StreamableHttpServerConfig,
27    StreamableHttpService,
28};
29use serde::Serialize;
30use tokio::sync::{Mutex, Semaphore};
31use tower_http::timeout::TimeoutLayer;
32
33use crate::config::{HttpPrincipal, HttpSessionAffinity};
34use crate::runtime::{configured_shell_runtime, ShellRuntime};
35use crate::server::{SessionIsolation, WinxService};
36
37mod observability;
38use observability::{observe_request_entry, PermitBody};
39
40pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
41
42/// The one effective request-body limit. It drives both the declared-length
43/// precheck and the SDK's streaming read limit, and at the default concurrency
44/// bounds buffered request bodies to 32 x 16 MiB = 512 MiB (bodies are only
45/// read after admission). Over HTTP this, not EditFiles' larger aggregate
46/// content cap, bounds a tool call; the SDK default it replaces was 4 MiB.
47pub const MAX_BODY_BYTES: usize = 16 * 1024 * 1024;
48/// Whole-request deadline. Client-profile wait budgets stay below it with margin.
49pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
50const RATE_WINDOW: Duration = Duration::from_secs(60);
51const MAX_RATE_TRACKED_IPS: usize = 4_096;
52const INVALID_AUTH_DELAY: Duration = Duration::from_millis(100);
53pub const DEFAULT_MAX_CONCURRENCY: usize = 32;
54/// Fixed-window budget of failed authentication attempts per source IP.
55/// Authenticated requests never consume it, so junk traffic sharing a tunnel's
56/// source IP cannot rate-limit a valid principal.
57pub const DEFAULT_REQUESTS_PER_MINUTE: u32 = 120;
58/// Fixed-window request budget per authenticated principal. Behind one reverse
59/// proxy every client shares a single source IP, so the IP window alone cannot
60/// keep one busy principal from starving the others.
61pub const DEFAULT_PRINCIPAL_REQUESTS_PER_MINUTE: u32 = 120;
62
63#[derive(Debug)]
64struct HealthState {
65    ready: AtomicBool,
66}
67
68#[derive(Serialize)]
69struct HealthResponse {
70    status: &'static str,
71    ready: bool,
72}
73
74/// Opaque per-request correlation shared by the HTTP middleware and MCP handler.
75/// It contains no client input and is safe to include in metadata-only usage logs.
76#[derive(Clone, Debug)]
77pub(crate) struct RequestCorrelation(String);
78
79impl RequestCorrelation {
80    fn new() -> Self {
81        Self(format!("r_{:032x}", rand::rng().random::<u128>()))
82    }
83
84    pub(crate) fn as_str(&self) -> &str {
85        &self.0
86    }
87}
88
89#[derive(Clone, Debug)]
90pub struct HttpServerOptions {
91    pub bind: String,
92    pub principals: Vec<HttpPrincipal>,
93    pub extra_hosts: Vec<String>,
94    pub allow_query_token: bool,
95    pub allow_non_loopback: bool,
96    pub session_affinity: HttpSessionAffinity,
97    pub max_concurrency: usize,
98    /// Failed-authentication attempts allowed per source IP and minute.
99    pub requests_per_minute: u32,
100    pub principal_requests_per_minute: u32,
101}
102
103impl HttpServerOptions {
104    pub fn single(
105        bind: impl Into<String>,
106        token: impl Into<String>,
107        extra_hosts: Vec<String>,
108        allow_query_token: bool,
109    ) -> Result<Self, BoxError> {
110        let token = token.into();
111        let principal = HttpPrincipal::new("default", token.trim(), false)?;
112        Ok(Self {
113            bind: bind.into(),
114            principals: vec![principal],
115            extra_hosts,
116            allow_query_token,
117            allow_non_loopback: false,
118            session_affinity: HttpSessionAffinity::Workspace,
119            max_concurrency: DEFAULT_MAX_CONCURRENCY,
120            requests_per_minute: DEFAULT_REQUESTS_PER_MINUTE,
121            principal_requests_per_minute: DEFAULT_PRINCIPAL_REQUESTS_PER_MINUTE,
122        })
123    }
124}
125
126pub async fn start_http_server(
127    bind: &str,
128    token: String,
129    extra_hosts: Vec<String>,
130    allow_query_token: bool,
131) -> Result<(), BoxError> {
132    let options = HttpServerOptions::single(bind, token, extra_hosts, allow_query_token)?;
133    start_http_server_with_options(options).await
134}
135
136pub async fn start_http_server_with_options(options: HttpServerOptions) -> Result<(), BoxError> {
137    let shell_runtime = configured_shell_runtime().await?;
138    start_http_server_with_runtime(options, shell_runtime).await
139}
140
141pub async fn start_http_server_with_runtime(
142    options: HttpServerOptions,
143    shell_runtime: Arc<dyn ShellRuntime>,
144) -> Result<(), BoxError> {
145    let bind = validate_options(&options)?;
146    let principal_names =
147        options.principals.iter().map(|principal| principal.name().to_string()).collect::<Vec<_>>();
148    let allow_query_token = options.allow_query_token;
149    let health_state = Arc::new(HealthState { ready: AtomicBool::new(true) });
150    let app = build_app(options, shell_runtime, Arc::clone(&health_state));
151
152    let listener = tokio::net::TcpListener::bind(bind).await?;
153    tracing::warn!(
154        bind = %bind,
155        principals = ?principal_names,
156        route = "/mcp",
157        "Winx remote MCP is network-reachable; keep it behind authenticated HTTPS"
158    );
159    if allow_query_token {
160        tracing::warn!(
161            "query-token authentication is enabled; URLs may expose credentials in proxy logs/history"
162        );
163    }
164    axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
165        .with_graceful_shutdown(shutdown_signal(health_state))
166        .await?;
167    Ok(())
168}
169
170/// Assemble the complete HTTP application for already-validated options.
171fn build_app(
172    options: HttpServerOptions,
173    shell_runtime: Arc<dyn ShellRuntime>,
174    health_state: Arc<HealthState>,
175) -> Router {
176    let shared_concurrency = Arc::new(Semaphore::new(options.max_concurrency));
177    let auth_failure_limiter = Arc::new(RateLimiter::new(options.requests_per_minute));
178    let principal_rate_limiter = Arc::new(RateLimiter::new(options.principal_requests_per_minute));
179
180    let mut config = StreamableHttpServerConfig::default();
181    config.legacy_session_mode = true;
182    config.allowed_hosts.extend(options.extra_hosts);
183    config.max_request_body_bytes = MAX_BODY_BYTES;
184
185    let shared = WinxService::with_runtime(SessionIsolation::Strict, shell_runtime);
186    let session_manager = Arc::new(LocalSessionManager::default());
187    let mcp_service =
188        StreamableHttpService::new(move || Ok(shared.clone()), session_manager.clone(), config);
189    let security = Arc::new(RouteSecurity {
190        principals: Arc::new(options.principals),
191        allow_query: options.allow_query_token,
192        session_affinity: options.session_affinity,
193        concurrency: shared_concurrency,
194        auth_failure_limiter,
195        principal_rate_limiter,
196        session_owners: Arc::new(SessionOwners::default()),
197        session_manager,
198    });
199    // The timeout sits INSIDE the security middleware: when it fires, the
200    // middleware still receives the 408 response and records the usage event.
201    // With the previous order the timed-out future was dropped before logging,
202    // so 408s were invisible in the usage log.
203    let protected_mcp = Router::new()
204        .nest_service("/mcp", mcp_service)
205        .layer(TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, REQUEST_TIMEOUT))
206        .layer(middleware::from_fn_with_state(security, enforce_route_security))
207        .layer(middleware::from_fn(reject_declared_oversized_body));
208    // Keep OAuth discovery probes and every unrelated path outside bearer auth.
209    // Winx does not advertise OAuth metadata, so those routes should be ordinary
210    // 404s instead of 401s that encourage clients to start a nonexistent flow.
211    let health_routes = Router::new()
212        .route("/healthz", get(healthz))
213        .route("/readyz", get(readyz))
214        .with_state(health_state);
215    Router::new().merge(health_routes).merge(protected_mcp)
216}
217
218/// Answer 413 for a declared oversized body before authentication or dispatch.
219///
220/// Streamed bodies without `Content-Length` are bounded by the SDK while it
221/// reads them (`max_request_body_bytes`, also a 413). tower-http's
222/// `RequestBodyLimitLayer` is deliberately not used: the SDK turns the body
223/// error it injects into a 500, so chunked overflows would not answer 413.
224async fn reject_declared_oversized_body(request: Request, next: Next) -> Response {
225    let declared = request
226        .headers()
227        .get(header::CONTENT_LENGTH)
228        .and_then(|value| value.to_str().ok())
229        .and_then(|value| value.trim().parse::<u64>().ok());
230    let limit = u64::try_from(MAX_BODY_BYTES).unwrap_or(u64::MAX);
231    if declared.is_some_and(|length| length > limit) {
232        return (
233            StatusCode::PAYLOAD_TOO_LARGE,
234            format!("Payload Too Large: request body exceeds {MAX_BODY_BYTES} bytes"),
235        )
236            .into_response();
237    }
238    next.run(request).await
239}
240
241async fn healthz() -> impl IntoResponse {
242    (StatusCode::OK, Json(HealthResponse { status: "ok", ready: true }))
243}
244
245async fn readyz(State(state): State<Arc<HealthState>>) -> impl IntoResponse {
246    let ready = state.ready.load(Ordering::Acquire);
247    let status = if ready { StatusCode::OK } else { StatusCode::SERVICE_UNAVAILABLE };
248    (status, Json(HealthResponse { status: if ready { "ready" } else { "draining" }, ready }))
249}
250
251async fn shutdown_signal(state: Arc<HealthState>) {
252    #[cfg(unix)]
253    {
254        let terminate = async {
255            match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
256                Ok(mut signal) => {
257                    signal.recv().await;
258                }
259                Err(error) => {
260                    tracing::warn!(%error, "failed to install SIGTERM handler");
261                    std::future::pending::<()>().await;
262                }
263            }
264        };
265        tokio::select! {
266            result = tokio::signal::ctrl_c() => {
267                if let Err(error) = result {
268                    tracing::warn!(%error, "failed while waiting for Ctrl-C");
269                }
270            }
271            () = terminate => {}
272        }
273    }
274    #[cfg(not(unix))]
275    if let Err(error) = tokio::signal::ctrl_c().await {
276        tracing::warn!(%error, "failed while waiting for Ctrl-C");
277    }
278
279    state.ready.store(false, Ordering::Release);
280    tracing::info!("HTTP shutdown requested; draining in-flight requests");
281}
282
283fn validate_options(options: &HttpServerOptions) -> Result<SocketAddr, BoxError> {
284    let bind: SocketAddr = options
285        .bind
286        .parse()
287        .map_err(|error| format!("invalid --bind socket address {:?}: {error}", options.bind))?;
288    if !bind.ip().is_loopback() && !options.allow_non_loopback {
289        return Err(format!(
290            "refusing non-loopback HTTP bind {bind}; pass --allow-non-loopback only behind a trusted firewall/tunnel"
291        )
292        .into());
293    }
294    if options.max_concurrency == 0 {
295        return Err("max_concurrency must be at least 1".into());
296    }
297    if options.requests_per_minute == 0 {
298        return Err("requests_per_minute must be at least 1".into());
299    }
300    if options.principal_requests_per_minute == 0 {
301        return Err("principal_requests_per_minute must be at least 1".into());
302    }
303    if options.principals.is_empty() {
304        return Err("at least one HTTP principal is required".into());
305    }
306    let mut names = HashSet::new();
307    for principal in &options.principals {
308        if !names.insert(principal.name()) {
309            return Err(format!("duplicate HTTP principal: {}", principal.name()).into());
310        }
311    }
312    Ok(bind)
313}
314
315#[derive(Clone)]
316struct RouteSecurity {
317    principals: Arc<Vec<HttpPrincipal>>,
318    allow_query: bool,
319    session_affinity: HttpSessionAffinity,
320    concurrency: Arc<Semaphore>,
321    auth_failure_limiter: Arc<RateLimiter<IpAddr>>,
322    principal_rate_limiter: Arc<RateLimiter<String>>,
323    session_owners: Arc<SessionOwners>,
324    session_manager: Arc<LocalSessionManager>,
325}
326
327/// Upper bound on remembered transport sessions. Past it the oldest entry is
328/// evicted and its SDK session closed at the same time, so an id never
329/// outlives the record that says who may use it.
330const MAX_TRACKED_SESSIONS: usize = 65_536;
331
332/// Which principal created each legacy MCP transport session (`Mcp-Session-Id`).
333///
334/// The SDK's session manager is shared by every principal and treats a known
335/// session id as sufficient to resume its stream or delete it. Winx records
336/// the creator when the `initialize` response mints the id and refuses any
337/// later request that presents the id under another principal's token.
338#[derive(Default)]
339struct SessionOwners {
340    inner: std::sync::Mutex<SessionOwnerTable>,
341}
342
343#[derive(Default)]
344struct SessionOwnerTable {
345    owners: HashMap<String, String>,
346    order: VecDeque<String>,
347}
348
349impl SessionOwners {
350    /// Only the recorded creator may present a minted `session_id`. An id this
351    /// process never minted is not an SDK session at all: modern stateless
352    /// clients may send one as a conversation identity, and the SDK answers
353    /// 404 itself if such an id reaches a legacy session path.
354    fn owner_matches(&self, session_id: &str, principal: &str) -> bool {
355        let table = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
356        table.owners.get(session_id).is_none_or(|owner| owner == principal)
357    }
358
359    /// Remember the creator of a freshly minted id. Returns the ids evicted
360    /// to stay within [`MAX_TRACKED_SESSIONS`]; the caller closes those SDK
361    /// sessions so a forgotten id can never be presented by anyone.
362    fn record(&self, session_id: &str, principal: &str) -> Vec<String> {
363        let mut table = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
364        if table.owners.contains_key(session_id) {
365            return Vec::new();
366        }
367        table.owners.insert(session_id.to_string(), principal.to_string());
368        table.order.push_back(session_id.to_string());
369        let mut evicted = Vec::new();
370        while table.order.len() > MAX_TRACKED_SESSIONS {
371            if let Some(oldest) = table.order.pop_front() {
372                table.owners.remove(&oldest);
373                evicted.push(oldest);
374            }
375        }
376        evicted
377    }
378
379    fn forget(&self, session_id: &str) {
380        let mut table = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
381        if table.owners.remove(session_id).is_some() {
382            table.order.retain(|id| id != session_id);
383        }
384    }
385}
386
387fn session_id_header(headers: &header::HeaderMap) -> Option<String> {
388    headers
389        .get("mcp-session-id")
390        .and_then(|value| value.to_str().ok())
391        .map(str::trim)
392        .filter(|value| !value.is_empty())
393        .map(ToString::to_string)
394}
395
396async fn enforce_route_security(
397    State(security): State<Arc<RouteSecurity>>,
398    ConnectInfo(peer): ConnectInfo<SocketAddr>,
399    request: Request,
400    next: Next,
401) -> Response {
402    let path = request.uri().path();
403    if path != "/mcp" && !path.starts_with("/mcp/") {
404        return next.run(request).await;
405    }
406    let (started, method, correlation) = observe_request_entry(peer, request.method());
407    // Authenticate before touching shared capacity. Behind a tunnel every caller
408    // shares one source IP, so junk traffic must not be able to spend the
409    // window or the concurrency permits a valid principal needs. The check is a
410    // constant-time digest comparison of headers only; the body is never read.
411    let Some(principal) =
412        authenticate_request(&request, &security.principals, security.allow_query)
413    else {
414        return reject_failed_auth(&security, peer, &correlation, request.uri().path()).await;
415    };
416
417    // A guessable token gets no answer that distinguishes it while its source
418    // IP is over the failed-auth budget; strong tokens are not locked out.
419    if principal.has_weak_token() && security.auth_failure_limiter.exhausted(&peer.ip()).await {
420        tokio::time::sleep(INVALID_AUTH_DELAY).await;
421        log_rejection(peer, None, &correlation, "ip_rate_limit", StatusCode::TOO_MANY_REQUESTS);
422        return retry_response(StatusCode::TOO_MANY_REQUESTS, "request rate limit exceeded\n");
423    }
424
425    let principal_name = principal.name().to_string();
426    if !security.principal_rate_limiter.allow(principal_name.clone()).await {
427        log_rejection(
428            peer,
429            Some(&principal_name),
430            &correlation,
431            "principal_rate_limit",
432            StatusCode::TOO_MANY_REQUESTS,
433        );
434        return retry_response(StatusCode::TOO_MANY_REQUESTS, "principal rate limit exceeded\n");
435    }
436    // A transport session id belongs to the principal that created it. Any
437    // other authenticated principal sees it as unknown, exactly like a stale
438    // id: the SDK would otherwise let it resume the stream or close it.
439    let presented_session = session_id_header(request.headers());
440    if let Some(session_id) = presented_session.as_deref() {
441        if !security.session_owners.owner_matches(session_id, &principal_name) {
442            tracing::warn!(
443                peer = %peer,
444                principal = %principal_name,
445                "rejected request presenting another principal's MCP session id"
446            );
447            log_rejection(
448                peer,
449                Some(&principal_name),
450                &correlation,
451                "foreign_session",
452                StatusCode::NOT_FOUND,
453            );
454            return (StatusCode::NOT_FOUND, "session not found\n").into_response();
455        }
456    }
457    let Ok(permit) = security.concurrency.clone().try_acquire_owned() else {
458        log_rejection(
459            peer,
460            Some(&principal_name),
461            &correlation,
462            "concurrency",
463            StatusCode::SERVICE_UNAVAILABLE,
464        );
465        return retry_response(StatusCode::SERVICE_UNAVAILABLE, "too many concurrent requests\n");
466    };
467    let mut request = request;
468    request.extensions_mut().insert(principal);
469    request.extensions_mut().insert(security.session_affinity);
470    request.extensions_mut().insert(correlation.clone());
471    let response = next.run(request).await;
472    if response.status().is_success() {
473        if let Some(minted) = session_id_header(response.headers()) {
474            for evicted in security.session_owners.record(&minted, &principal_name) {
475                let manager = security.session_manager.clone();
476                tokio::spawn(async move {
477                    let id: rmcp::transport::common::server_side_http::SessionId =
478                        Arc::from(evicted.as_str());
479                    let _ = manager.close_session(&id).await;
480                });
481            }
482        }
483        if method == axum::http::Method::DELETE {
484            if let Some(session_id) = presented_session.as_deref() {
485                security.session_owners.forget(session_id);
486            }
487        }
488    }
489    // Preserve the legacy event and duration as the response-ready observation.
490    // Body termination is a separate metadata-only event from PermitBody.
491    let response_ready = Instant::now();
492    tracing::info!(
493        target: "winx::usage",
494        event = "http_request",
495        phase = "response_ready",
496        principal = %principal_name,
497        request_id = %correlation.as_str(),
498        peer = %peer,
499        method = %method,
500        status = response.status().as_u16(),
501        duration_ms = u64::try_from(response_ready.saturating_duration_since(started).as_millis())
502            .unwrap_or(u64::MAX),
503        "http request"
504    );
505    // The permit travels with the body, including while a streaming response is
506    // open. An already-empty body can now log completion after response-ready.
507    response.map(|body| {
508        axum::body::Body::new(PermitBody::new(body, permit).observe(
509            correlation,
510            started,
511            response_ready,
512        ))
513    })
514}
515
516/// Failed authentication: the only traffic that spends the per-IP window.
517///
518/// Every failure is delayed, including those already over budget, so an
519/// over-budget guess is never answered faster. No concurrency permit is held
520/// while waiting. A strong valid token still succeeds while its IP is over
521/// budget (its 32-byte minimum makes guessing infeasible); a weak one does not.
522async fn reject_failed_auth(
523    security: &RouteSecurity,
524    peer: SocketAddr,
525    correlation: &RequestCorrelation,
526    path: &str,
527) -> Response {
528    let within_budget = security.auth_failure_limiter.allow(peer.ip()).await;
529    tokio::time::sleep(INVALID_AUTH_DELAY).await;
530    if !within_budget {
531        log_rejection(peer, None, correlation, "ip_rate_limit", StatusCode::TOO_MANY_REQUESTS);
532        return retry_response(StatusCode::TOO_MANY_REQUESTS, "request rate limit exceeded\n");
533    }
534    tracing::warn!(peer = %peer, path = %path, "rejected request with missing or invalid HTTP token");
535    log_rejection(peer, None, correlation, "auth", StatusCode::UNAUTHORIZED);
536    (StatusCode::UNAUTHORIZED, "missing or invalid token\n").into_response()
537}
538
539/// One redacted `winx::usage` event per admission rejection, so overload and
540/// auth failures are visible in reports instead of vanishing before the
541/// per-request event.
542fn log_rejection(
543    peer: SocketAddr,
544    principal: Option<&str>,
545    correlation: &RequestCorrelation,
546    reason: &str,
547    status: StatusCode,
548) {
549    tracing::info!(
550        target: "winx::usage",
551        event = "http_rejected",
552        request_id = %correlation.as_str(),
553        principal = principal.unwrap_or(""),
554        peer = %peer,
555        reason,
556        status = status.as_u16(),
557        "http request rejected"
558    );
559}
560fn retry_response(status: StatusCode, body: &'static str) -> Response {
561    let mut response = (status, body).into_response();
562    response.headers_mut().insert(header::RETRY_AFTER, HeaderValue::from_static("1"));
563    response
564}
565
566#[derive(Debug)]
567struct RateWindow {
568    started_at: Instant,
569    last_seen: Instant,
570    count: u32,
571}
572
573#[derive(Debug)]
574/// Fixed-window limiter keyed by source IP (failed-auth budget) or by
575/// authenticated principal (fairness behind a shared proxy).
576struct RateLimiter<K> {
577    requests_per_window: u32,
578    windows: Mutex<HashMap<K, RateWindow>>,
579}
580
581impl<K: std::hash::Hash + Eq + Clone> RateLimiter<K> {
582    fn new(requests_per_window: u32) -> Self {
583        Self { requests_per_window, windows: Mutex::new(HashMap::new()) }
584    }
585
586    async fn allow(&self, peer: K) -> bool {
587        let now = Instant::now();
588        let mut windows = self.windows.lock().await;
589        windows.retain(|_, window| now.duration_since(window.last_seen) < RATE_WINDOW * 2);
590        if !windows.contains_key(&peer) && windows.len() >= MAX_RATE_TRACKED_IPS {
591            if let Some(oldest) =
592                windows.iter().min_by_key(|(_, window)| window.last_seen).map(|(ip, _)| ip.clone())
593            {
594                windows.remove(&oldest);
595            }
596        }
597        let window =
598            windows.entry(peer).or_insert(RateWindow { started_at: now, last_seen: now, count: 0 });
599        if now.duration_since(window.started_at) >= RATE_WINDOW {
600            window.started_at = now;
601            window.count = 0;
602        }
603        window.last_seen = now;
604        if window.count >= self.requests_per_window {
605            return false;
606        }
607        window.count = window.count.saturating_add(1);
608        true
609    }
610
611    /// Whether `peer` has spent its budget in the current window, without
612    /// consuming any of it.
613    async fn exhausted(&self, peer: &K) -> bool {
614        let windows = self.windows.lock().await;
615        windows.get(peer).is_some_and(|window| {
616            Instant::now().duration_since(window.started_at) < RATE_WINDOW
617                && window.count >= self.requests_per_window
618        })
619    }
620}
621
622fn authenticate_request(
623    request: &Request,
624    principals: &[HttpPrincipal],
625    allow_query: bool,
626) -> Option<HttpPrincipal> {
627    let header = bearer_token(request);
628    let query = allow_query.then(|| query_token(request)).flatten();
629    [header, query].into_iter().flatten().find_map(|presented| {
630        principals.iter().find(|principal| principal.matches_token(presented)).cloned()
631    })
632}
633
634fn bearer_token(request: &Request) -> Option<&str> {
635    let value = request.headers().get(header::AUTHORIZATION)?.to_str().ok()?;
636    let (scheme, token) = value.split_once(' ')?;
637    scheme.eq_ignore_ascii_case("bearer").then_some(token.trim())
638}
639
640fn query_token(request: &Request) -> Option<&str> {
641    request.uri().query()?.split('&').find_map(|pair| pair.strip_prefix("token="))
642}
643
644#[cfg(test)]
645mod admission_tests {
646    #![allow(clippy::expect_used)]
647    use std::sync::Arc;
648
649    use tokio::sync::Semaphore;
650
651    use super::{PermitBody, RateLimiter};
652
653    #[tokio::test]
654    async fn principal_windows_are_independent_of_each_other() {
655        let limiter: RateLimiter<String> = RateLimiter::new(2);
656        assert!(limiter.allow("left".to_string()).await);
657        assert!(limiter.allow("left".to_string()).await);
658        assert!(!limiter.allow("left".to_string()).await, "left exhausted its window");
659        assert!(limiter.allow("right".to_string()).await, "right is unaffected");
660    }
661
662    #[tokio::test]
663    async fn permit_is_released_only_when_the_body_finishes() {
664        let semaphore = Arc::new(Semaphore::new(1));
665        let permit = semaphore.clone().try_acquire_owned().expect("permit");
666        let body =
667            axum::body::Body::new(PermitBody::new(axum::body::Body::from("payload"), permit));
668        assert_eq!(semaphore.available_permits(), 0, "held while the body is pending");
669        let bytes = axum::body::to_bytes(body, usize::MAX).await.expect("read body");
670        assert_eq!(&bytes[..], b"payload");
671        assert_eq!(semaphore.available_permits(), 1, "released with the consumed body");
672
673        let permit = semaphore.clone().try_acquire_owned().expect("permit again");
674        let body =
675            axum::body::Body::new(PermitBody::new(axum::body::Body::from("abandoned"), permit));
676        drop(body);
677        assert_eq!(semaphore.available_permits(), 1, "released when a client disconnects");
678    }
679}
680
681#[cfg(test)]
682mod session_owner_tests {
683    use super::SessionOwners;
684
685    #[test]
686    fn minted_session_ids_belong_to_their_first_creator() {
687        let owners = SessionOwners::default();
688        assert!(owners.owner_matches("conv-a", "left"), "unminted ids are not SDK sessions");
689        assert!(owners.record("s1", "left").is_empty(), "nothing evicted");
690        assert!(owners.owner_matches("s1", "left"));
691        assert!(!owners.owner_matches("s1", "right"), "another principal");
692        assert!(owners.record("s1", "right").is_empty(), "nothing evicted");
693        assert!(owners.owner_matches("s1", "left"), "first creator wins");
694        owners.forget("s1");
695        assert!(owners.owner_matches("s1", "right"), "closed ids are plain unknown ids again");
696    }
697
698    #[test]
699    fn eviction_hands_back_the_ids_whose_sdk_sessions_must_close() {
700        let owners = SessionOwners::default();
701        assert!(owners.record("oldest", "left").is_empty(), "nothing evicted");
702        let mut evicted = Vec::new();
703        for index in 0..super::MAX_TRACKED_SESSIONS {
704            evicted.extend(owners.record(&format!("s{index}"), "left"));
705        }
706        assert_eq!(evicted, vec!["oldest".to_string()]);
707        assert!(owners.owner_matches(&format!("s{}", super::MAX_TRACKED_SESSIONS - 1), "left"));
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    #![allow(clippy::expect_used)]
714
715    use std::net::{IpAddr, Ipv4Addr};
716    use std::sync::atomic::{AtomicBool, Ordering};
717    use std::sync::Arc;
718
719    use axum::body::Body;
720    use axum::extract::{Request, State};
721    use axum::http::StatusCode;
722    use axum::response::IntoResponse as _;
723
724    use super::{
725        authenticate_request, readyz, validate_options, HealthState, HttpServerOptions, RateLimiter,
726    };
727    use crate::config::HttpPrincipal;
728
729    fn request(uri: &str, authorization: Option<&str>) -> Request {
730        let mut builder = Request::builder().uri(uri);
731        if let Some(authorization) = authorization {
732            builder = builder.header(axum::http::header::AUTHORIZATION, authorization);
733        }
734        builder.body(Body::empty()).expect("test request")
735    }
736
737    fn principal(name: &str, token_byte: char) -> HttpPrincipal {
738        let token = token_byte.to_string().repeat(32);
739        HttpPrincipal::new(name, &token, false).expect("valid principal")
740    }
741
742    fn strong_options(bind: &str) -> HttpServerOptions {
743        HttpServerOptions {
744            bind: bind.to_string(),
745            principals: vec![principal("default", 'x')],
746            extra_hosts: Vec::new(),
747            allow_query_token: false,
748            allow_non_loopback: false,
749            session_affinity: crate::config::HttpSessionAffinity::Workspace,
750            max_concurrency: super::DEFAULT_MAX_CONCURRENCY,
751            requests_per_minute: super::DEFAULT_REQUESTS_PER_MINUTE,
752            principal_requests_per_minute: super::DEFAULT_PRINCIPAL_REQUESTS_PER_MINUTE,
753        }
754    }
755
756    #[test]
757    fn bearer_and_opt_in_query_tokens_select_the_matching_principal() {
758        let principals = vec![principal("chatgpt", 'a'), principal("automation", 'b')];
759        let header = request("/mcp", Some(&format!("Bearer {}", "a".repeat(32))));
760        let matched = authenticate_request(&header, &principals, false).expect("header principal");
761        assert_eq!(matched.name(), "chatgpt");
762
763        let query_uri = format!("/mcp?token={}", "b".repeat(32));
764        assert!(authenticate_request(&request(&query_uri, None), &principals, false).is_none());
765        let matched = authenticate_request(&request(&query_uri, None), &principals, true)
766            .expect("query principal");
767        assert_eq!(matched.name(), "automation");
768    }
769
770    #[test]
771    fn non_loopback_binding_requires_explicit_override() {
772        assert!(validate_options(&strong_options("127.0.0.1:8000")).is_ok());
773        assert!(validate_options(&strong_options("0.0.0.0:8000")).is_err());
774
775        let mut public = strong_options("0.0.0.0:8000");
776        public.allow_non_loopback = true;
777        assert!(validate_options(&public).is_ok());
778    }
779
780    #[tokio::test]
781    async fn rate_limiter_rejects_excess_requests() {
782        let limiter = RateLimiter::new(2);
783        let peer = IpAddr::V4(Ipv4Addr::LOCALHOST);
784        assert!(limiter.allow(peer).await);
785        assert!(limiter.allow(peer).await);
786        assert!(!limiter.allow(peer).await);
787    }
788
789    #[test]
790    fn duplicate_principal_names_are_rejected() {
791        let mut options = strong_options("127.0.0.1:8000");
792        options.principals = vec![principal("same", 'a'), principal("same", 'b')];
793        assert!(validate_options(&options).is_err());
794    }
795
796    #[tokio::test]
797    async fn readiness_turns_unavailable_while_draining() {
798        let state = Arc::new(HealthState { ready: AtomicBool::new(true) });
799        let ready = readyz(State(Arc::clone(&state))).await.into_response();
800        assert_eq!(ready.status(), StatusCode::OK);
801
802        state.ready.store(false, Ordering::Release);
803        let draining = readyz(State(state)).await.into_response();
804        assert_eq!(draining.status(), StatusCode::SERVICE_UNAVAILABLE);
805    }
806}
807
808#[cfg(test)]
809mod observability_tests;
810
811#[cfg(test)]
812mod security_tests;