winx_code_agent/http_server.rs
1//! Optional remote MCP transport over Streamable HTTP.
2//!
3//! This is what lets a cloud client (e.g. ChatGPT "developer mode" custom
4//! connectors) talk to winx. The default stdio transport is for local clients
5//! (Claude Desktop, Cursor); this one serves the MCP protocol over HTTP at
6//! `/mcp` so it can sit behind an HTTPS tunnel (cloudflared/ngrok).
7//!
8//! # SECURITY
9//! winx exposes arbitrary shell execution and filesystem access. Serving it over
10//! the network is effectively remote code execution on this machine. Therefore:
11//! - a non-empty bearer token is **required**; every request must present it via
12//! `Authorization: Bearer <token>` or a `?token=<token>` query parameter;
13//! - bind to a loopback address and put an authenticated HTTPS tunnel in front —
14//! never expose this straight to `0.0.0.0` on an untrusted network;
15//! - turn it off when you're done testing.
16
17// Module docs name products (ChatGPT, OAuth, cloudflared) — prose, not code idents.
18#![allow(clippy::doc_markdown)]
19
20use std::sync::Arc;
21
22use axum::{
23 extract::{Request, State},
24 http::StatusCode,
25 middleware::{self, Next},
26 response::{IntoResponse, Response},
27 Router,
28};
29use rmcp::transport::streamable_http_server::{
30 session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
31};
32
33use crate::server::{SessionIsolation, WinxService};
34
35type BoxError = Box<dyn std::error::Error + Send + Sync>;
36
37/// Start the Streamable HTTP MCP server.
38///
39/// - `bind`: socket address to listen on, e.g. `127.0.0.1:8000`.
40/// - `token`: shared secret required on every request (must be non-empty).
41/// - `extra_hosts`: additional `Host` authorities to accept beyond loopback —
42/// add your tunnel hostname here (e.g. `abc.trycloudflare.com`), otherwise the
43/// built-in DNS-rebinding guard rejects requests coming through the tunnel.
44pub async fn start_http_server(
45 bind: &str,
46 token: String,
47 extra_hosts: Vec<String>,
48) -> Result<(), BoxError> {
49 if token.trim().is_empty() {
50 return Err("refusing to start HTTP transport without a token (RCE exposure)".into());
51 }
52
53 let mut config = StreamableHttpServerConfig::default();
54 config.stateful_mode = true;
55 config.allowed_hosts.extend(extra_hosts);
56
57 // One shared WinxService across every request. Remote clients like ChatGPT
58 // are effectively stateless — they don't reuse the MCP session between tool
59 // calls — so a per-session service would throw away the shell that
60 // `Initialize` created before `BashCommand` runs ("Bash state not
61 // initialized"). Sharing one instance keeps shells alive for the server's
62 // lifetime; isolation between logical sessions is provided per `thread_id`
63 // by the service's internal session registry.
64 //
65 // Strict isolation: with many clients behind one shared token, an empty
66 // `thread_id` must NOT fall back to whoever was last active (that would land
67 // one client in another's shell). Strict mode disables that fallback. Two
68 // clients that deliberately reuse the same explicit `thread_id` still share a
69 // shell — real multi-tenant isolation needs per-client tokens, which the
70 // single shared-token model doesn't provide.
71 let shared = WinxService::with_isolation(SessionIsolation::Strict);
72 let mcp_service = StreamableHttpService::new(
73 move || Ok(shared.clone()),
74 Arc::new(LocalSessionManager::default()),
75 config,
76 );
77
78 let app = Router::new()
79 .nest_service("/mcp", mcp_service)
80 .layer(middleware::from_fn_with_state(Arc::new(token), require_token));
81
82 let listener = tokio::net::TcpListener::bind(bind).await?;
83 tracing::warn!(
84 "winx remote MCP transport on http://{bind}/mcp — shell/file access is now \
85 network-reachable. Keep it behind an HTTPS tunnel and shut it down when done."
86 );
87 axum::serve(listener, app).await?;
88 Ok(())
89}
90
91/// Reject any request that doesn't carry the shared token.
92async fn require_token(State(token): State<Arc<String>>, request: Request, next: Next) -> Response {
93 if request_has_token(&request, &token) {
94 next.run(request).await
95 } else {
96 (StatusCode::UNAUTHORIZED, "missing or invalid token\n").into_response()
97 }
98}
99
100/// True if the request presents the token via `Authorization: Bearer` or `?token=`.
101fn request_has_token(request: &Request, expected: &str) -> bool {
102 let header_match = request
103 .headers()
104 .get(axum::http::header::AUTHORIZATION)
105 .and_then(|value| value.to_str().ok())
106 .and_then(|value| value.strip_prefix("Bearer "))
107 .is_some_and(|presented| constant_time_eq(presented.trim(), expected));
108
109 let query_match = request.uri().query().is_some_and(|query| {
110 query
111 .split('&')
112 .filter_map(|pair| pair.split_once('='))
113 .any(|(key, value)| key == "token" && constant_time_eq(value, expected))
114 });
115
116 header_match || query_match
117}
118
119/// Length-aware byte comparison that avoids early-exit on the first mismatch.
120fn constant_time_eq(a: &str, b: &str) -> bool {
121 let (a, b) = (a.as_bytes(), b.as_bytes());
122 if a.len() != b.len() {
123 return false;
124 }
125 a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
126}
127
128#[cfg(test)]
129mod tests {
130 use super::constant_time_eq;
131
132 #[test]
133 fn token_comparison() {
134 assert!(constant_time_eq("s3cret", "s3cret"));
135 assert!(!constant_time_eq("s3cret", "s3creT"));
136 assert!(!constant_time_eq("s3cret", "s3cret-longer"));
137 assert!(!constant_time_eq("", "x"));
138 }
139}