agent_client_protocol_http/
server.rs1use std::sync::Arc;
2
3use agent_client_protocol::{Client, ConnectTo};
4use axum::{
5 Router,
6 extract::WebSocketUpgrade,
7 extract::ws::rejection::WebSocketUpgradeRejection,
8 http::{HeaderName, HeaderValue, Method, StatusCode, header, header::InvalidHeaderValue},
9 response::{IntoResponse, Response},
10 routing::{delete, get, post},
11};
12use tower_http::cors::{AllowOrigin, CorsLayer};
13
14use crate::connection::ConnectionRegistry;
15
16#[derive(Debug, Clone)]
17pub struct ServerOptions {
18 pub path: String,
19 pub cors: CorsOptions,
20 pub health_endpoint: bool,
21}
22
23impl Default for ServerOptions {
24 fn default() -> Self {
25 Self {
26 path: "/acp".to_string(),
27 cors: CorsOptions::default(),
28 health_endpoint: true,
29 }
30 }
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub enum CorsOptions {
35 #[default]
36 Disabled,
37 AllowOrigins(Vec<HeaderValue>),
38 AllowAnyOrigin,
39}
40
41impl CorsOptions {
42 #[must_use]
43 pub fn disabled() -> Self {
44 Self::Disabled
45 }
46
47 #[must_use]
48 pub fn allow_any_origin() -> Self {
49 Self::AllowAnyOrigin
50 }
51
52 pub fn allow_origins<I, S>(origins: I) -> Result<Self, InvalidHeaderValue>
53 where
54 I: IntoIterator<Item = S>,
55 S: AsRef<str>,
56 {
57 origins
58 .into_iter()
59 .map(|origin| HeaderValue::from_str(origin.as_ref()))
60 .collect::<Result<Vec<_>, _>>()
61 .map(Self::AllowOrigins)
62 }
63
64 fn allow_origin_layer(&self) -> Option<AllowOrigin> {
65 match self {
66 Self::Disabled => None,
67 Self::AllowOrigins(origins) => Some(AllowOrigin::list(origins.clone())),
68 Self::AllowAnyOrigin => Some(AllowOrigin::any()),
69 }
70 }
71
72 fn allows_origin(&self, origin: Option<&HeaderValue>) -> bool {
73 let Some(origin) = origin else {
74 return true;
75 };
76 match self {
77 Self::Disabled => false,
78 Self::AllowOrigins(origins) => origins.iter().any(|allowed| allowed == origin),
79 Self::AllowAnyOrigin => true,
80 }
81 }
82}
83
84#[derive(Clone)]
85struct ServerState {
86 registry: Arc<ConnectionRegistry>,
87 cors: CorsOptions,
88}
89
90pub struct AcpHttpServer {
91 registry: Arc<ConnectionRegistry>,
92 options: ServerOptions,
93}
94
95impl std::fmt::Debug for AcpHttpServer {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.debug_struct("AcpHttpServer")
98 .field("options", &self.options)
99 .finish_non_exhaustive()
100 }
101}
102
103impl AcpHttpServer {
104 pub fn new<F, C>(factory: F) -> Self
105 where
106 F: Fn() -> C + Send + Sync + 'static,
107 C: ConnectTo<Client>,
108 {
109 Self {
110 registry: Arc::new(ConnectionRegistry::new(Arc::new(factory))),
111 options: ServerOptions::default(),
112 }
113 }
114
115 #[must_use]
116 pub fn with_options(mut self, options: ServerOptions) -> Self {
117 self.options = options;
118 self
119 }
120
121 pub fn into_router(self) -> Router {
122 let registry = self.registry.clone();
123 let path = self.options.path.clone();
124 let cors = self.options.cors.clone();
125 let state = ServerState {
126 registry: registry.clone(),
127 cors: cors.clone(),
128 };
129
130 let mut router = Router::new()
131 .route(
132 &path,
133 post(crate::http_server::handle_post).with_state(registry.clone()),
134 )
135 .route(&path, get(handle_get).with_state(state))
136 .route(
137 &path,
138 delete(crate::http_server::handle_delete).with_state(registry),
139 );
140
141 if self.options.health_endpoint {
142 router = router.route("/health", get(health));
143 }
144
145 if let Some(allow_origin) = cors.allow_origin_layer() {
146 router = router.layer(default_cors(allow_origin));
147 }
148
149 router
150 }
151}
152
153async fn health() -> &'static str {
154 "ok"
155}
156
157fn default_cors(allow_origin: AllowOrigin) -> CorsLayer {
158 CorsLayer::new()
159 .allow_origin(allow_origin)
160 .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
161 .allow_headers([
162 header::CONTENT_TYPE,
163 header::ACCEPT,
164 HeaderName::from_static("acp-connection-id"),
165 HeaderName::from_static("acp-session-id"),
166 header::SEC_WEBSOCKET_VERSION,
167 header::SEC_WEBSOCKET_KEY,
168 header::CONNECTION,
169 header::UPGRADE,
170 ])
171 .expose_headers([
172 HeaderName::from_static("acp-connection-id"),
173 HeaderName::from_static("acp-session-id"),
174 ])
175}
176
177async fn handle_get(
178 ws_upgrade: Result<WebSocketUpgrade, WebSocketUpgradeRejection>,
179 axum::extract::State(state): axum::extract::State<ServerState>,
180 request: axum::http::Request<axum::body::Body>,
181) -> Response {
182 match ws_upgrade {
183 Ok(ws) => {
184 if !state
185 .cors
186 .allows_origin(request.headers().get(header::ORIGIN))
187 {
188 return (StatusCode::FORBIDDEN, "WebSocket origin not allowed").into_response();
189 }
190 crate::websocket_server::handle_ws_upgrade(state.registry, ws)
191 }
192 Err(_) => crate::http_server::handle_get(state.registry, request).await,
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use axum::body::Body;
200 use tower::{Layer as _, ServiceExt as _, service_fn};
201
202 #[test]
203 fn cors_is_disabled_by_default() {
204 assert_eq!(ServerOptions::default().cors, CorsOptions::Disabled);
205 }
206
207 #[test]
208 fn disabled_cors_rejects_browser_origin_for_websockets() {
209 let origin = HeaderValue::from_static("http://localhost:5173");
210
211 assert!(CorsOptions::disabled().allows_origin(None));
212 assert!(!CorsOptions::disabled().allows_origin(Some(&origin)));
213 }
214
215 #[test]
216 fn cors_allowlist_matches_configured_origins() {
217 let allowed = HeaderValue::from_static("http://localhost:5173");
218 let denied = HeaderValue::from_static("http://localhost:3000");
219 let cors = CorsOptions::allow_origins(["http://localhost:5173"]).unwrap();
220
221 assert!(cors.allows_origin(None));
222 assert!(cors.allows_origin(Some(&allowed)));
223 assert!(!cors.allows_origin(Some(&denied)));
224 }
225
226 #[test]
227 fn explicit_allow_any_origin_accepts_browser_origins() {
228 let origin = HeaderValue::from_static("https://example.com");
229
230 assert!(CorsOptions::allow_any_origin().allows_origin(Some(&origin)));
231 }
232
233 #[tokio::test]
234 async fn allow_any_origin_uses_wildcard_cors_header() {
235 let response = default_cors(
236 CorsOptions::allow_any_origin()
237 .allow_origin_layer()
238 .expect("CORS layer"),
239 )
240 .layer(service_fn(|_: axum::http::Request<Body>| async {
241 Ok::<_, std::convert::Infallible>(Response::new(Body::empty()))
242 }))
243 .oneshot(
244 axum::http::Request::builder()
245 .header(header::ORIGIN, "https://example.com")
246 .body(Body::empty())
247 .unwrap(),
248 )
249 .await
250 .unwrap();
251
252 assert_eq!(
253 response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
254 Some(&HeaderValue::from_static("*"))
255 );
256 assert!(response.headers().get(header::VARY).is_none());
257 }
258
259 #[tokio::test]
260 async fn allowlisted_origins_vary_by_origin() {
261 let response = default_cors(
262 CorsOptions::allow_origins(["https://example.com"])
263 .unwrap()
264 .allow_origin_layer()
265 .expect("CORS layer"),
266 )
267 .layer(service_fn(|_: axum::http::Request<Body>| async {
268 Ok::<_, std::convert::Infallible>(Response::new(Body::empty()))
269 }))
270 .oneshot(
271 axum::http::Request::builder()
272 .header(header::ORIGIN, "https://example.com")
273 .body(Body::empty())
274 .unwrap(),
275 )
276 .await
277 .unwrap();
278
279 assert_eq!(
280 response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
281 Some(&HeaderValue::from_static("https://example.com"))
282 );
283 assert_eq!(
284 response.headers().get(header::VARY),
285 Some(&HeaderValue::from_static("origin"))
286 );
287 }
288}