Skip to main content

lean_ctx/http_server/
mod.rs

1use std::net::SocketAddr;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use anyhow::{Context, Result, anyhow};
6use axum::{
7    Router,
8    extract::Json,
9    extract::Query,
10    extract::State,
11    http::{Request, StatusCode, header},
12    middleware::{self, Next},
13    response::sse::{Event as SseEvent, KeepAlive, Sse},
14    response::{IntoResponse, Response},
15    routing::get,
16};
17use futures::Stream;
18use rmcp::transport::{StreamableHttpServerConfig, StreamableHttpService};
19use serde::Deserialize;
20use serde_json::Value;
21use tokio::sync::broadcast;
22use tokio::time::{Duration, Instant};
23
24use crate::core::context_os::ContextOsMetrics;
25use crate::engine::ContextEngine;
26use crate::tools::LeanCtxServer;
27
28pub mod context_views;
29pub mod roi_webhook;
30pub mod savings_ingest;
31pub mod savings_summary;
32pub mod team;
33pub mod team_billing;
34
35/// Wrapper stream that calls `record_sse_disconnect` on drop.
36use std::pin::Pin;
37
38pub(crate) struct SseDisconnectGuard<I> {
39    pub(crate) inner: Pin<Box<dyn Stream<Item = I> + Send>>,
40    pub(crate) metrics: Arc<ContextOsMetrics>,
41}
42
43impl<I> Stream for SseDisconnectGuard<I> {
44    type Item = I;
45
46    fn poll_next(
47        mut self: Pin<&mut Self>,
48        cx: &mut std::task::Context<'_>,
49    ) -> std::task::Poll<Option<Self::Item>> {
50        self.inner.as_mut().poll_next(cx)
51    }
52}
53
54impl<I> Drop for SseDisconnectGuard<I> {
55    fn drop(&mut self) {
56        self.metrics.record_sse_disconnect();
57    }
58}
59
60const MAX_ID_LEN: usize = 64;
61
62fn sanitize_id(raw: &str) -> String {
63    let trimmed = raw.trim();
64    if trimmed.is_empty() {
65        return "default".to_string();
66    }
67    let cleaned: String = trimmed
68        .chars()
69        .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
70        .take(MAX_ID_LEN)
71        .collect();
72    if cleaned.is_empty() {
73        "default".to_string()
74    } else {
75        cleaned
76    }
77}
78
79#[derive(Clone, Debug)]
80pub struct HttpServerConfig {
81    pub host: String,
82    pub port: u16,
83    pub project_root: PathBuf,
84    pub auth_token: Option<String>,
85    pub stateful_mode: bool,
86    pub json_response: bool,
87    pub disable_host_check: bool,
88    pub allowed_hosts: Vec<String>,
89    pub max_body_bytes: usize,
90    pub max_concurrency: usize,
91    pub max_rps: u32,
92    pub rate_burst: u32,
93    pub request_timeout_ms: u64,
94}
95
96impl Default for HttpServerConfig {
97    fn default() -> Self {
98        let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
99        Self {
100            host: "127.0.0.1".to_string(),
101            port: 8080,
102            project_root,
103            auth_token: None,
104            stateful_mode: false,
105            json_response: true,
106            disable_host_check: false,
107            allowed_hosts: Vec::new(),
108            max_body_bytes: 2 * 1024 * 1024,
109            max_concurrency: 32,
110            max_rps: 50,
111            rate_burst: 100,
112            request_timeout_ms: 30_000,
113        }
114    }
115}
116
117impl HttpServerConfig {
118    pub fn validate(&self) -> Result<()> {
119        let host = self.host.trim().to_lowercase();
120        let is_loopback = host == "127.0.0.1" || host == "localhost" || host == "::1";
121        if !is_loopback && self.auth_token.as_deref().unwrap_or("").is_empty() {
122            return Err(anyhow!(
123                "Refusing to bind to host='{host}' without auth. Provide --auth-token (or bind to 127.0.0.1)."
124            ));
125        }
126        Ok(())
127    }
128
129    pub fn effective_auth_token(&self) -> Option<String> {
130        if let Some(ref token) = self.auth_token
131            && !token.is_empty()
132        {
133            return Some(token.clone());
134        }
135        let host = self.host.trim().to_lowercase();
136        let is_loopback = host == "127.0.0.1" || host == "localhost" || host == "::1";
137        if is_loopback {
138            let auto_token = crate::core::session_token::generate_token();
139            eprintln!(
140                "[lean-ctx] Auto-generated auth token for loopback: {auto_token}\n\
141                 Pass as Bearer token or set --auth-token explicitly."
142            );
143            Some(auto_token)
144        } else {
145            None
146        }
147    }
148
149    fn mcp_http_config(&self) -> StreamableHttpServerConfig {
150        let mut cfg = StreamableHttpServerConfig::default()
151            .with_stateful_mode(self.stateful_mode)
152            .with_json_response(self.json_response);
153
154        if self.disable_host_check {
155            tracing::warn!(
156                "⚠ --disable-host-check is active: DNS rebinding protection is OFF. \
157                 Do NOT use this in production or on non-loopback interfaces."
158            );
159            cfg = cfg.disable_allowed_hosts();
160            return cfg;
161        }
162
163        if !self.allowed_hosts.is_empty() {
164            cfg = cfg.with_allowed_hosts(self.allowed_hosts.clone());
165            return cfg;
166        }
167
168        // Keep rmcp's secure loopback defaults; also allow the configured host (if it's loopback).
169        let host = self.host.trim();
170        if host == "127.0.0.1" || host == "localhost" || host == "::1" {
171            cfg.allowed_hosts.push(host.to_string());
172        }
173
174        cfg
175    }
176}
177
178#[derive(Clone)]
179struct AppState {
180    token: Option<String>,
181    concurrency: Arc<tokio::sync::Semaphore>,
182    rate: Arc<RateLimiter>,
183    project_root: String,
184    timeout: Duration,
185    server: LeanCtxServer,
186}
187
188#[derive(Debug)]
189struct RateLimiter {
190    max_rps: f64,
191    burst: f64,
192    state: tokio::sync::Mutex<RateState>,
193}
194
195#[derive(Debug, Clone, Copy)]
196struct RateState {
197    tokens: f64,
198    last: Instant,
199}
200
201impl RateLimiter {
202    fn new(max_rps: u32, burst: u32) -> Self {
203        let now = Instant::now();
204        Self {
205            max_rps: (max_rps.max(1)) as f64,
206            burst: (burst.max(1)) as f64,
207            state: tokio::sync::Mutex::new(RateState {
208                tokens: (burst.max(1)) as f64,
209                last: now,
210            }),
211        }
212    }
213
214    async fn allow(&self) -> bool {
215        let mut s = self.state.lock().await;
216        let now = Instant::now();
217        let elapsed = now.saturating_duration_since(s.last);
218        let refill = elapsed.as_secs_f64() * self.max_rps;
219        s.tokens = (s.tokens + refill).min(self.burst);
220        s.last = now;
221        if s.tokens >= 1.0 {
222            s.tokens -= 1.0;
223            true
224        } else {
225            false
226        }
227    }
228}
229
230async fn auth_middleware(
231    State(state): State<AppState>,
232    req: Request<axum::body::Body>,
233    next: Next,
234) -> Response {
235    if state.token.is_none() {
236        return next.run(req).await;
237    }
238
239    if req.uri().path() == "/health" {
240        return next.run(req).await;
241    }
242
243    let expected = state.token.as_deref().unwrap_or("");
244    let Some(h) = req.headers().get(header::AUTHORIZATION) else {
245        return json_error(
246            StatusCode::UNAUTHORIZED,
247            "unauthorized",
248            "missing Authorization header",
249        );
250    };
251    let Ok(s) = h.to_str() else {
252        return json_error(
253            StatusCode::UNAUTHORIZED,
254            "unauthorized",
255            "malformed Authorization header",
256        );
257    };
258    let Some(token) = s
259        .strip_prefix("Bearer ")
260        .or_else(|| s.strip_prefix("bearer "))
261    else {
262        return json_error(
263            StatusCode::UNAUTHORIZED,
264            "unauthorized",
265            "Authorization must use the Bearer scheme",
266        );
267    };
268    if !constant_time_eq(token.as_bytes(), expected.as_bytes()) {
269        return json_error(
270            StatusCode::UNAUTHORIZED,
271            "unauthorized",
272            "invalid bearer token",
273        );
274    }
275
276    next.run(req).await
277}
278
279/// Structured REST error envelope: `{ "error": <human message>, "error_code": <stable code> }`.
280///
281/// `error_code` is the stable, machine-readable string SDKs switch on; `error` carries the
282/// human-facing message. Used for every REST (non-A2A) error so clients branch on a code
283/// instead of parsing prose. The A2A JSON-RPC surface keeps its own `-32xxx` envelope.
284pub(crate) fn json_error(status: StatusCode, error_code: &str, message: &str) -> Response {
285    (
286        status,
287        Json(serde_json::json!({ "error": message, "error_code": error_code })),
288    )
289        .into_response()
290}
291
292fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
293    use subtle::ConstantTimeEq;
294    if a.len() != b.len() {
295        return false;
296    }
297    bool::from(a.ct_eq(b))
298}
299
300async fn rate_limit_middleware(
301    State(state): State<AppState>,
302    req: Request<axum::body::Body>,
303    next: Next,
304) -> Response {
305    if !state.rate.allow().await {
306        return StatusCode::TOO_MANY_REQUESTS.into_response();
307    }
308    next.run(req).await
309}
310
311async fn concurrency_middleware(
312    State(state): State<AppState>,
313    req: Request<axum::body::Body>,
314    next: Next,
315) -> Response {
316    let Ok(permit) = state.concurrency.clone().try_acquire_owned() else {
317        return StatusCode::TOO_MANY_REQUESTS.into_response();
318    };
319    let resp = next.run(req).await;
320    drop(permit);
321    resp
322}
323
324async fn health() -> impl IntoResponse {
325    (StatusCode::OK, "ok\n")
326}
327
328async fn v1_shutdown() -> impl IntoResponse {
329    tokio::spawn(async {
330        tokio::time::sleep(Duration::from_millis(100)).await;
331        std::process::exit(0);
332    });
333    (StatusCode::OK, "shutting down\n")
334}
335
336#[derive(Debug, Deserialize)]
337#[serde(rename_all = "camelCase")]
338struct IndexEnsureBody {
339    root: String,
340    #[serde(default)]
341    extra_roots: Vec<String>,
342}
343
344/// Daemon-side index delegation (#460). A thin-client session POSTs the repo it
345/// needs warmed and the daemon — the single long-lived indexer — builds it once
346/// in the background (deduped per root). Every other session for the same root
347/// then load-shares the on-disk result via the `graph-idx`/`bm25-idx`
348/// cross-process locks instead of running its own scan, so N concurrent sessions
349/// cost ~one index pass machine-wide instead of N. Returns immediately; the
350/// build runs in the orchestrator's own worker thread.
351async fn v1_index_ensure(Json(body): Json<IndexEnsureBody>) -> impl IntoResponse {
352    if body.root.trim().is_empty() {
353        return (StatusCode::BAD_REQUEST, "root is required\n");
354    }
355    let root = body.root;
356    let extra = body.extra_roots;
357    tokio::task::spawn_blocking(move || {
358        crate::core::index_orchestrator::ensure_all_background(&root);
359        if !extra.is_empty() {
360            crate::core::index_orchestrator::ensure_extra_roots_background(&root, &extra);
361        }
362    });
363    (StatusCode::OK, "{\"status\":\"ok\"}\n")
364}
365
366#[derive(Debug, Deserialize)]
367#[serde(rename_all = "camelCase")]
368struct ToolCallBody {
369    name: String,
370    #[serde(default)]
371    arguments: Option<Value>,
372    #[serde(default)]
373    _workspace_id: Option<String>,
374    #[serde(default)]
375    _channel_id: Option<String>,
376}
377
378#[derive(Debug, Deserialize)]
379#[serde(rename_all = "camelCase")]
380struct EventsQuery {
381    #[serde(default)]
382    workspace_id: Option<String>,
383    #[serde(default)]
384    channel_id: Option<String>,
385    #[serde(default)]
386    since: Option<i64>,
387    #[serde(default)]
388    limit: Option<usize>,
389    /// Comma-separated event kind filter (e.g. `tool_call,session_start`).
390    /// When set, only matching events are delivered via SSE.
391    #[serde(default)]
392    kind: Option<String>,
393}
394
395async fn v1_manifest(State(state): State<AppState>) -> impl IntoResponse {
396    let _ = state;
397    let v = crate::core::mcp_manifest::manifest_value();
398    (StatusCode::OK, Json(v))
399}
400
401/// `GET /v1/capabilities` — discovery document describing what this instance
402/// supports (presets, tools, read modes, features, extensions, contract
403/// versions). See `docs/contracts/capabilities-contract-v1.md`.
404async fn v1_capabilities(State(state): State<AppState>) -> impl IntoResponse {
405    let _ = state;
406    (
407        StatusCode::OK,
408        Json(crate::core::server_capabilities::capabilities_value()),
409    )
410}
411
412/// `GET /v1/openapi.json` — OpenAPI 3.0 document for the public `/v1` surface,
413/// generated from the in-code endpoint inventory (`core::openapi`).
414async fn v1_openapi(State(state): State<AppState>) -> impl IntoResponse {
415    let _ = state;
416    (StatusCode::OK, Json(crate::core::openapi::openapi_value()))
417}
418
419#[derive(Debug, Deserialize)]
420#[serde(rename_all = "camelCase")]
421struct ToolsQuery {
422    #[serde(default)]
423    offset: Option<usize>,
424    #[serde(default)]
425    limit: Option<usize>,
426}
427
428async fn v1_tools(State(state): State<AppState>, Query(q): Query<ToolsQuery>) -> impl IntoResponse {
429    let _ = state;
430    let v = crate::core::mcp_manifest::manifest_value();
431    let tools = v
432        .get("tools")
433        .and_then(|t| t.get("granular"))
434        .cloned()
435        .unwrap_or(Value::Array(vec![]));
436
437    let all = tools.as_array().cloned().unwrap_or_default();
438    let total = all.len();
439    let offset = q.offset.unwrap_or(0).min(total);
440    let limit = q.limit.unwrap_or(200).min(500);
441    let page = all.into_iter().skip(offset).take(limit).collect::<Vec<_>>();
442
443    (
444        StatusCode::OK,
445        Json(serde_json::json!({
446            "tools": page,
447            "total": total,
448            "offset": offset,
449            "limit": limit,
450        })),
451    )
452}
453
454async fn v1_tool_call(
455    State(state): State<AppState>,
456    Json(body): Json<ToolCallBody>,
457) -> impl IntoResponse {
458    let engine = ContextEngine::from_server(state.server.clone());
459    match tokio::time::timeout(
460        state.timeout,
461        engine.call_tool_value(&body.name, body.arguments),
462    )
463    .await
464    {
465        Ok(Ok(v)) => (StatusCode::OK, Json(serde_json::json!({ "result": v }))).into_response(),
466        Ok(Err(e)) => {
467            tracing::warn!("tool call error: {e}");
468            json_error(
469                StatusCode::BAD_REQUEST,
470                "tool_error",
471                "tool execution failed",
472            )
473        }
474        Err(_) => json_error(
475            StatusCode::GATEWAY_TIMEOUT,
476            "request_timeout",
477            "tool call timed out",
478        ),
479    }
480}
481
482async fn v1_events(
483    State(state): State<AppState>,
484    Query(q): Query<EventsQuery>,
485) -> Sse<impl Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
486    use crate::core::context_os::{ContextEventV1, RedactionLevel, redact_event_payload};
487
488    let ws = sanitize_id(&q.workspace_id.unwrap_or_else(|| "default".to_string()));
489    let ch = sanitize_id(&q.channel_id.unwrap_or_else(|| "default".to_string()));
490    let _ = &state.project_root;
491    let since = q.since.unwrap_or(0);
492    let limit = q.limit.unwrap_or(200).min(1000);
493    let redaction = RedactionLevel::RefsOnly;
494
495    let kind_filter: Option<Vec<String>> = q
496        .kind
497        .as_deref()
498        .map(|k| k.split(',').map(|s| s.trim().to_string()).collect());
499
500    let rt = crate::core::context_os::runtime();
501    let replay = rt.bus.read(&ws, &ch, since, limit);
502
503    let replay = if let Some(ref kinds) = kind_filter {
504        replay
505            .into_iter()
506            .filter(|ev| kinds.contains(&ev.kind))
507            .collect()
508    } else {
509        replay
510    };
511
512    let rx = if let Some(ref kinds) = kind_filter {
513        let kind_refs: Vec<&str> = kinds.iter().map(String::as_str).collect();
514        let filter = crate::core::context_os::TopicFilter::kinds(&kind_refs);
515        if let Some(sub) = rt.bus.subscribe_filtered(&ws, &ch, filter) {
516            crate::core::context_os::SubscriptionKind::Filtered(sub)
517        } else {
518            tracing::warn!("SSE subscriber limit reached for {ws}/{ch}");
519            let (_, rx) = broadcast::channel::<ContextEventV1>(1);
520            crate::core::context_os::SubscriptionKind::Unfiltered(rx)
521        }
522    } else if let Some(sub) = rt.bus.subscribe(&ws, &ch) {
523        crate::core::context_os::SubscriptionKind::Unfiltered(sub)
524    } else {
525        tracing::warn!("SSE subscriber limit reached for {ws}/{ch}");
526        let (_, rx) = broadcast::channel::<ContextEventV1>(1);
527        crate::core::context_os::SubscriptionKind::Unfiltered(rx)
528    };
529
530    rt.metrics.record_sse_connect();
531    rt.metrics.record_events_replayed(replay.len() as u64);
532    rt.metrics.record_workspace_active(&ws);
533
534    let bus = rt.bus.clone();
535    let metrics = rt.metrics.clone();
536    let pending: std::collections::VecDeque<ContextEventV1> = replay.into();
537
538    let stream = futures::stream::unfold(
539        (
540            pending,
541            rx,
542            ws.clone(),
543            ch.clone(),
544            since,
545            redaction,
546            bus,
547            metrics,
548        ),
549        |(mut pending, mut rx, ws, ch, mut last_id, redaction, bus, metrics)| async move {
550            if let Some(mut ev) = pending.pop_front() {
551                last_id = ev.id;
552                redact_event_payload(&mut ev, redaction);
553                let data = serde_json::to_string(&ev).unwrap_or_else(|_| "{}".to_string());
554                let evt = SseEvent::default()
555                    .id(ev.id.to_string())
556                    .event(ev.kind)
557                    .data(data);
558                return Some((
559                    Ok(evt),
560                    (pending, rx, ws, ch, last_id, redaction, bus, metrics),
561                ));
562            }
563
564            loop {
565                match rx.recv().await {
566                    Ok(mut ev) if ev.id > last_id => {
567                        last_id = ev.id;
568                        redact_event_payload(&mut ev, redaction);
569                        let data = serde_json::to_string(&ev).unwrap_or_else(|_| "{}".to_string());
570                        let evt = SseEvent::default()
571                            .id(ev.id.to_string())
572                            .event(ev.kind)
573                            .data(data);
574                        return Some((
575                            Ok(evt),
576                            (pending, rx, ws, ch, last_id, redaction, bus, metrics),
577                        ));
578                    }
579                    Ok(_) => {}
580                    Err(broadcast::error::RecvError::Closed) => return None,
581                    Err(broadcast::error::RecvError::Lagged(skipped)) => {
582                        let missed = bus.read(&ws, &ch, last_id, skipped as usize);
583                        metrics.record_events_replayed(missed.len() as u64);
584                        for ev in missed {
585                            last_id = last_id.max(ev.id);
586                            pending.push_back(ev);
587                        }
588                    }
589                }
590            }
591        },
592    );
593
594    let metrics_ref = rt.metrics.clone();
595    let guarded = SseDisconnectGuard {
596        inner: Box::pin(stream),
597        metrics: metrics_ref,
598    };
599
600    Sse::new(guarded).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
601}
602
603#[derive(Debug, Deserialize)]
604struct AuditEventsQuery {
605    #[serde(default = "default_audit_limit")]
606    limit: usize,
607}
608
609fn default_audit_limit() -> usize {
610    100
611}
612
613async fn v1_audit_events(Query(q): Query<AuditEventsQuery>) -> impl IntoResponse {
614    let capped = q.limit.min(1000);
615    let boundary_events = crate::core::memory_boundary::load_audit_events(capped);
616    let trail_events = crate::core::audit_trail::load_recent(capped);
617
618    Json(serde_json::json!({
619        "cross_project_events": boundary_events,
620        "audit_trail": trail_events,
621    }))
622}
623
624async fn v1_metrics(State(_state): State<AppState>) -> impl IntoResponse {
625    let rt = crate::core::context_os::runtime();
626    let snap = rt.metrics.snapshot();
627    (
628        StatusCode::OK,
629        Json(serde_json::to_value(snap).unwrap_or_default()),
630    )
631}
632
633const MAX_HANDOFF_PAYLOAD_BYTES: usize = 1_000_000;
634const MAX_HANDOFF_FILES: usize = 50;
635
636async fn v1_a2a_handoff(
637    State(state): State<AppState>,
638    Json(body): Json<Value>,
639) -> impl IntoResponse {
640    let envelope = match crate::core::a2a_transport::parse_envelope(
641        &serde_json::to_string(&body).unwrap_or_default(),
642    ) {
643        Ok(env) => env,
644        Err(e) => {
645            tracing::warn!("a2a handoff parse error: {e}");
646            return (
647                StatusCode::BAD_REQUEST,
648                Json(serde_json::json!({"error": "invalid_envelope"})),
649            );
650        }
651    };
652
653    if envelope.payload_json.len() > MAX_HANDOFF_PAYLOAD_BYTES {
654        tracing::warn!(
655            "a2a handoff payload too large: {} bytes (limit {MAX_HANDOFF_PAYLOAD_BYTES})",
656            envelope.payload_json.len()
657        );
658        return (
659            StatusCode::PAYLOAD_TOO_LARGE,
660            Json(serde_json::json!({"error": "payload_too_large"})),
661        );
662    }
663
664    let rt = crate::core::context_os::runtime();
665    rt.bus.append(
666        &state.project_root,
667        "a2a",
668        &crate::core::context_os::ContextEventKindV1::SessionMutated,
669        Some(&envelope.sender.agent_id),
670        serde_json::json!({
671            "type": "handoff_received",
672            "content_type": format!("{:?}", envelope.content_type),
673            "sender": envelope.sender.agent_id,
674            "payload_size": envelope.payload_json.len(),
675        }),
676    );
677
678    match envelope.content_type {
679        crate::core::a2a_transport::TransportContentType::ContextPackage => {
680            let dir = std::path::Path::new(&state.project_root)
681                .join(".lean-ctx")
682                .join("handoffs")
683                .join("packages");
684            let _ = std::fs::create_dir_all(&dir);
685            evict_oldest_files(&dir, MAX_HANDOFF_FILES);
686            let out = dir.join(format!(
687                "ctx-{}.{}",
688                chrono::Utc::now().format("%Y%m%d_%H%M%S"),
689                crate::core::contracts::PACKAGE_EXTENSION
690            ));
691            if let Err(e) = std::fs::write(&out, &envelope.payload_json) {
692                tracing::error!("a2a handoff write failed: {e}");
693                return (
694                    StatusCode::INTERNAL_SERVER_ERROR,
695                    Json(serde_json::json!({"error": "write_failed"})),
696                );
697            }
698            (
699                StatusCode::OK,
700                Json(serde_json::json!({
701                    "status": "received",
702                    "content_type": "context_package",
703                })),
704            )
705        }
706        crate::core::a2a_transport::TransportContentType::HandoffBundle => {
707            // Signature enforcement at the network boundary (GL #465): a
708            // payload that is not a parseable bundle, or whose signature
709            // material does not verify, is rejected fail-closed before it
710            // ever touches disk. Legacy unsigned bundles are stored with the
711            // status surfaced so the importer can warn.
712            let bundle =
713                match crate::core::handoff_transfer_bundle::parse_bundle_v1(&envelope.payload_json)
714                {
715                    Ok(b) => b,
716                    Err(e) => {
717                        tracing::warn!("a2a handoff rejected: not a valid bundle: {e}");
718                        return (
719                            StatusCode::BAD_REQUEST,
720                            Json(serde_json::json!({"error": "invalid_bundle"})),
721                        );
722                    }
723                };
724            let signature =
725                match crate::core::handoff_transfer_bundle::check_bundle_signature(&bundle) {
726                    crate::core::handoff_transfer_bundle::BundleSignatureStatus::Invalid(
727                        reason,
728                    ) => {
729                        tracing::warn!("a2a handoff rejected: signature invalid: {reason}");
730                        crate::core::audit_trail::record(
731                            crate::core::audit_trail::AuditEntryData {
732                                agent_id: envelope.sender.agent_id.clone(),
733                                tool: "http:/v1/a2a/handoff".to_string(),
734                                action: Some("import_signature_invalid".to_string()),
735                                input_hash: String::new(),
736                                output_tokens: 0,
737                                role: crate::core::roles::active_role_name(),
738                                event_type:
739                                    crate::core::audit_trail::AuditEventType::SecurityViolation,
740                            },
741                        );
742                        return (
743                            StatusCode::BAD_REQUEST,
744                            Json(serde_json::json!({"error": "invalid_signature"})),
745                        );
746                    }
747                    crate::core::handoff_transfer_bundle::BundleSignatureStatus::Verified(
748                        signer,
749                    ) => {
750                        serde_json::json!({"status": "verified", "signer": signer})
751                    }
752                    crate::core::handoff_transfer_bundle::BundleSignatureStatus::Unsigned => {
753                        serde_json::json!({"status": "unsigned"})
754                    }
755                };
756
757            let dir = std::path::Path::new(&state.project_root)
758                .join(".lean-ctx")
759                .join("handoffs");
760            let _ = std::fs::create_dir_all(&dir);
761            evict_oldest_files(&dir, MAX_HANDOFF_FILES);
762            let out = dir.join(format!(
763                "received-{}.json",
764                chrono::Utc::now().format("%Y%m%d_%H%M%S")
765            ));
766            if let Err(e) = std::fs::write(&out, &envelope.payload_json) {
767                tracing::error!("a2a handoff write failed: {e}");
768                return (
769                    StatusCode::INTERNAL_SERVER_ERROR,
770                    Json(serde_json::json!({"error": "write_failed"})),
771                );
772            }
773            (
774                StatusCode::OK,
775                Json(serde_json::json!({
776                    "status": "received",
777                    "content_type": "handoff_bundle",
778                    "signature": signature,
779                })),
780            )
781        }
782        _ => (
783            StatusCode::OK,
784            Json(serde_json::json!({
785                "status": "received",
786                "content_type": format!("{:?}", envelope.content_type),
787            })),
788        ),
789    }
790}
791
792fn evict_oldest_files(dir: &std::path::Path, max_files: usize) {
793    let Ok(entries) = std::fs::read_dir(dir) else {
794        return;
795    };
796    let mut files: Vec<(std::time::SystemTime, std::path::PathBuf)> = entries
797        .filter_map(|e| {
798            let e = e.ok()?;
799            let meta = e.metadata().ok()?;
800            if meta.is_file() {
801                Some((meta.modified().unwrap_or(std::time::UNIX_EPOCH), e.path()))
802            } else {
803                None
804            }
805        })
806        .collect();
807
808    if files.len() < max_files {
809        return;
810    }
811    files.sort_by_key(|(mtime, _)| *mtime);
812    let to_remove = files.len().saturating_sub(max_files.saturating_sub(1));
813    for (_, path) in files.into_iter().take(to_remove) {
814        let _ = std::fs::remove_file(path);
815    }
816}
817
818async fn a2a_jsonrpc(Json(body): Json<Value>) -> impl IntoResponse {
819    let req: crate::core::a2a::a2a_compat::JsonRpcRequest = match serde_json::from_value(body) {
820        Ok(r) => r,
821        Err(e) => {
822            tracing::debug!("a2a JSON-RPC parse error: {e}");
823            return (
824                StatusCode::BAD_REQUEST,
825                Json(serde_json::json!({
826                    "jsonrpc": "2.0",
827                    "id": null,
828                    "error": {"code": -32700, "message": "invalid request"}
829                })),
830            );
831        }
832    };
833    let resp = crate::core::a2a::a2a_compat::handle_a2a_jsonrpc(&req);
834    let json = serde_json::to_value(resp).unwrap_or_default();
835    (StatusCode::OK, Json(json))
836}
837
838async fn v1_a2a_agent_card(State(state): State<AppState>) -> impl IntoResponse {
839    let card = crate::core::a2a::agent_card::build_agent_card(&state.project_root);
840    (
841        StatusCode::OK,
842        [(header::CONTENT_TYPE, "application/json")],
843        Json(card),
844    )
845}
846
847async fn mcp_server_card() -> impl IntoResponse {
848    let card = serde_json::json!({
849        "name": "lean-ctx",
850        "version": env!("CARGO_PKG_VERSION"),
851        "description": "Context Infrastructure Layer — compression, caching, governance for AI agents",
852        "capabilities": {
853            "tools": true,
854            "resources": false,
855            "prompts": false,
856            "sampling": false
857        },
858        "tool_categories": [
859            {"name": "file_operations", "tools": ["ctx_read", "ctx_search", "ctx_tree", "ctx_edit"], "avg_token_cost": 150},
860            {"name": "session_management", "tools": ["ctx_session", "ctx_compress", "ctx_dedup", "ctx_preload"], "avg_token_cost": 80},
861            {"name": "intelligence", "tools": ["ctx_knowledge", "ctx_semantic_search", "ctx_graph", "ctx_overview"], "avg_token_cost": 200},
862            {"name": "agent_ops", "tools": ["ctx_agent", "ctx_handoff", "ctx_task", "ctx_share"], "avg_token_cost": 120}
863        ],
864        "features": {
865            "compression": "deterministic AST-based, 40-70% token reduction",
866            "caching": "session-scoped with zstd, re-reads ~13 tokens",
867            "audit_trail": "SHA-256 chained JSONL",
868            "rbac": "5 built-in roles with capability-based access",
869            "sandboxing": "Level 0 (subprocess) + Level 1 (OS-level)",
870            "secret_detection": "8 regex patterns + custom"
871        },
872        "security": {
873            "path_jail": true,
874            "rate_limiting": true,
875            "budget_tracking": true,
876            "signed_handoffs": true,
877            "timing_safe_auth": true
878        }
879    });
880    Json(card)
881}
882
883async fn v1_agents_register(
884    State(state): State<AppState>,
885    Json(body): Json<Value>,
886) -> impl IntoResponse {
887    let agent_type = body
888        .get("agent_type")
889        .and_then(|v| v.as_str())
890        .unwrap_or("unknown");
891    let role = body.get("role").and_then(|v| v.as_str());
892    let project_root = body
893        .get("project_root")
894        .and_then(|v| v.as_str())
895        .unwrap_or(&state.project_root);
896
897    let mut registry = crate::core::agents::AgentRegistry::load_or_create();
898    let agent_id = registry.register(agent_type, role, project_root);
899    let _ = registry.save();
900
901    Json(serde_json::json!({
902        "agent_id": agent_id,
903        "status": "registered"
904    }))
905}
906
907async fn v1_agents_heartbeat(Json(body): Json<Value>) -> impl IntoResponse {
908    let agent_id = body.get("agent_id").and_then(|v| v.as_str()).unwrap_or("");
909    let mut registry = crate::core::agents::AgentRegistry::load_or_create();
910    registry.update_heartbeat(agent_id);
911    let _ = registry.save();
912    Json(serde_json::json!({"status": "ok"}))
913}
914
915async fn v1_agents_list() -> impl IntoResponse {
916    let registry = crate::core::agents::AgentRegistry::load_or_create();
917    let active = registry.list_active(None);
918    Json(serde_json::json!({
919        "agents": active.iter().map(|a| serde_json::json!({
920            "agent_id": a.agent_id,
921            "agent_type": a.agent_type,
922            "role": a.role,
923            "status": a.status.to_string(),
924            "last_active": a.last_active.to_rfc3339(),
925        })).collect::<Vec<_>>()
926    }))
927}
928
929async fn v1_agents_deregister(Json(body): Json<Value>) -> impl IntoResponse {
930    let agent_id = body.get("agent_id").and_then(|v| v.as_str()).unwrap_or("");
931    let mut registry = crate::core::agents::AgentRegistry::load_or_create();
932    registry.set_status(
933        agent_id,
934        crate::core::agents::AgentStatus::Finished,
935        Some("deregistered via API"),
936    );
937    let _ = registry.save();
938    Json(serde_json::json!({"status": "deregistered"}))
939}
940
941async fn v1_agents_events_sse()
942-> Sse<impl Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
943    let stream = futures::stream::unfold(0usize, |last_count| async move {
944        loop {
945            tokio::time::sleep(Duration::from_secs(5)).await;
946            let registry = crate::core::agents::AgentRegistry::load_or_create();
947            let active = registry.list_active(None);
948            let count = active.len();
949            if count != last_count {
950                let data = serde_json::json!({
951                    "type": "agents_changed",
952                    "active_count": count,
953                    "agents": active.iter().map(|a| &a.agent_id).collect::<Vec<_>>(),
954                });
955                return Some((
956                    Ok::<_, std::convert::Infallible>(SseEvent::default().data(data.to_string())),
957                    count,
958                ));
959            }
960        }
961    });
962
963    Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
964}
965
966fn build_app_router(cfg: &HttpServerConfig) -> Router {
967    build_app_router_with_auth(cfg, true)
968}
969
970fn build_app_router_with_auth(cfg: &HttpServerConfig, require_auth: bool) -> Router {
971    let project_root = cfg.project_root.to_string_lossy().to_string();
972    let service_project_root = project_root.clone();
973    let service_factory = move || -> Result<LeanCtxServer, std::io::Error> {
974        Ok(LeanCtxServer::new_shared_with_context(
975            &service_project_root,
976            "default",
977            "default",
978        ))
979    };
980    let mcp_http = StreamableHttpService::new(
981        service_factory,
982        Arc::new(
983            rmcp::transport::streamable_http_server::session::local::LocalSessionManager::default(),
984        ),
985        cfg.mcp_http_config(),
986    );
987
988    let rest_server = LeanCtxServer::new_shared_with_context(&project_root, "default", "default");
989
990    let state = AppState {
991        token: if require_auth {
992            cfg.effective_auth_token()
993        } else {
994            None
995        },
996        concurrency: Arc::new(tokio::sync::Semaphore::new(cfg.max_concurrency.max(1))),
997        rate: Arc::new(RateLimiter::new(cfg.max_rps, cfg.rate_burst)),
998        project_root,
999        timeout: Duration::from_millis(cfg.request_timeout_ms.max(1)),
1000        server: rest_server,
1001    };
1002
1003    Router::new()
1004        .route("/health", get(health))
1005        .route("/v1/shutdown", axum::routing::post(v1_shutdown))
1006        .route("/v1/index/ensure", axum::routing::post(v1_index_ensure))
1007        .route("/v1/manifest", get(v1_manifest))
1008        .route("/v1/capabilities", get(v1_capabilities))
1009        .route("/v1/openapi.json", get(v1_openapi))
1010        .route("/v1/tools", get(v1_tools))
1011        .route("/v1/tools/call", axum::routing::post(v1_tool_call))
1012        .route("/v1/events", get(v1_events))
1013        .route(
1014            "/v1/context/summary",
1015            get(context_views::v1_context_summary),
1016        )
1017        .route("/v1/events/search", get(context_views::v1_events_search))
1018        .route("/v1/events/lineage", get(context_views::v1_event_lineage))
1019        .route("/v1/metrics", get(v1_metrics))
1020        .route("/v1/audit/events", get(v1_audit_events))
1021        .route("/v1/a2a/handoff", axum::routing::post(v1_a2a_handoff))
1022        .route("/v1/a2a/agent-card", get(v1_a2a_agent_card))
1023        .route("/.well-known/agent.json", get(v1_a2a_agent_card))
1024        .route("/.well-known/mcp-server.json", get(mcp_server_card))
1025        .route("/a2a", axum::routing::post(a2a_jsonrpc))
1026        .route(
1027            "/v1/agents/register",
1028            axum::routing::post(v1_agents_register),
1029        )
1030        .route(
1031            "/v1/agents/heartbeat",
1032            axum::routing::post(v1_agents_heartbeat),
1033        )
1034        .route("/v1/agents/list", get(v1_agents_list))
1035        .route(
1036            "/v1/agents/deregister",
1037            axum::routing::post(v1_agents_deregister),
1038        )
1039        .route("/v1/agents/events", get(v1_agents_events_sse))
1040        .fallback_service(mcp_http)
1041        .layer(axum::extract::DefaultBodyLimit::max(cfg.max_body_bytes))
1042        .layer(middleware::from_fn_with_state(
1043            state.clone(),
1044            rate_limit_middleware,
1045        ))
1046        .layer(middleware::from_fn_with_state(
1047            state.clone(),
1048            concurrency_middleware,
1049        ))
1050        .layer(middleware::from_fn_with_state(
1051            state.clone(),
1052            auth_middleware,
1053        ))
1054        .with_state(state)
1055}
1056
1057pub async fn serve(cfg: HttpServerConfig) -> Result<()> {
1058    crate::core::protocol::set_mcp_context(true);
1059    cfg.validate()?;
1060
1061    // Surface any path-jail relaxation inherited from the launch env or config,
1062    // so a loosened boundary is never silent (GH security audit, finding 3).
1063    crate::core::pathjail::warn_if_relaxed();
1064
1065    crate::core::plugins::PluginManager::init();
1066    crate::core::savings_autopush::spawn_if_enabled();
1067
1068    // Pre-warm the project indices in the background for this long-lived HTTP
1069    // server. The stdio path deliberately stays lazy — short-lived respawns must
1070    // not each pay a full graph + BM25 scan (#453) — but `serve` is a single,
1071    // persistent process: one background build gives the first heavy/search tool
1072    // call a warm index instead of racing a cold scan of a large project root
1073    // against the per-request timeout (the SDK-conformance regression, GL #395).
1074    // The build is deduped per root and idle CPU settles flat once it completes
1075    // (the memory guard backs off), so #453 idle hygiene is preserved.
1076    let warm_root = cfg.project_root.to_string_lossy().to_string();
1077    if !warm_root.is_empty() {
1078        crate::core::index_orchestrator::ensure_all_background(&warm_root);
1079    }
1080
1081    let addr: SocketAddr = format!("{}:{}", cfg.host, cfg.port)
1082        .parse()
1083        .context("invalid host/port")?;
1084
1085    let app = build_app_router(&cfg);
1086
1087    let listener = tokio::net::TcpListener::bind(addr)
1088        .await
1089        .with_context(|| format!("bind {addr}"))?;
1090
1091    tracing::info!(
1092        "lean-ctx Streamable HTTP server listening on http://{addr} (project_root={})",
1093        cfg.project_root.display()
1094    );
1095
1096    axum::serve(listener, app)
1097        .with_graceful_shutdown(async move {
1098            let _ = tokio::signal::ctrl_c().await;
1099        })
1100        .await
1101        .context("http server")?;
1102
1103    fire_session_end();
1104    Ok(())
1105}
1106
1107/// Fire the `on_session_end` plugin hook synchronously (best-effort, bounded by
1108/// each plugin's own timeout) so listeners run before the process exits. A
1109/// no-op unless a plugin declares the hook.
1110pub(crate) fn fire_session_end() {
1111    if crate::core::plugins::PluginManager::has_listener("on_session_end") {
1112        let _ = crate::core::plugins::PluginManager::fire_hook(
1113            &crate::core::plugins::executor::HookPoint::OnSessionEnd,
1114        );
1115    }
1116}
1117
1118#[cfg(windows)]
1119impl axum::serve::Listener for crate::ipc::NamedPipeListener {
1120    type Io = tokio::net::windows::named_pipe::NamedPipeServer;
1121    type Addr = String;
1122
1123    async fn accept(&mut self) -> (Self::Io, Self::Addr) {
1124        loop {
1125            match self.accept_pipe().await {
1126                Ok(pipe) => return (pipe, self.name().to_string()),
1127                Err(e) => {
1128                    tracing::error!("named pipe accept error: {e}");
1129                    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1130                }
1131            }
1132        }
1133    }
1134
1135    fn local_addr(&self) -> std::io::Result<Self::Addr> {
1136        Ok(self.name().to_string())
1137    }
1138}
1139
1140/// Serve the daemon over a platform-independent IPC channel (UDS on Unix,
1141/// Named Pipes on Windows).
1142pub async fn serve_ipc(cfg: HttpServerConfig, addr: crate::ipc::DaemonAddr) -> Result<()> {
1143    cfg.validate()?;
1144
1145    crate::core::plugins::PluginManager::init();
1146    crate::core::savings_autopush::spawn_if_enabled();
1147
1148    match addr {
1149        #[cfg(unix)]
1150        crate::ipc::DaemonAddr::Unix(ref path) => {
1151            let app = build_app_router_with_auth(&cfg, false);
1152            let listener = crate::ipc::bind_listener(&addr)?;
1153
1154            tracing::info!(
1155                "lean-ctx daemon listening on {} (project_root={})",
1156                path.display(),
1157                cfg.project_root.display()
1158            );
1159
1160            axum::serve(listener, app.into_make_service())
1161                .with_graceful_shutdown(async move {
1162                    let _ = tokio::signal::ctrl_c().await;
1163                })
1164                .await
1165                .context("ipc server")?;
1166            Ok(())
1167        }
1168        #[cfg(windows)]
1169        crate::ipc::DaemonAddr::NamedPipe(ref name) => {
1170            let app = build_app_router_with_auth(&cfg, false);
1171            let listener = crate::ipc::bind_listener(&addr)?;
1172
1173            tracing::info!(
1174                "lean-ctx daemon listening on {} (project_root={})",
1175                name,
1176                cfg.project_root.display()
1177            );
1178
1179            axum::serve(listener, app.into_make_service())
1180                .with_graceful_shutdown(async move {
1181                    let _ = tokio::signal::ctrl_c().await;
1182                })
1183                .await
1184                .context("ipc server")?;
1185            Ok(())
1186        }
1187    }
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192    use super::*;
1193    use axum::body::Body;
1194    use axum::http::Request;
1195    use futures::StreamExt;
1196    use rmcp::transport::{StreamableHttpServerConfig, StreamableHttpService};
1197    use serde_json::json;
1198    use tower::ServiceExt;
1199
1200    async fn read_first_sse_message(body: Body) -> String {
1201        let mut stream = body.into_data_stream();
1202        let mut buf: Vec<u8> = Vec::new();
1203        for _ in 0..32 {
1204            let next = tokio::time::timeout(Duration::from_secs(2), stream.next()).await;
1205            let Ok(Some(Ok(bytes))) = next else {
1206                break;
1207            };
1208            buf.extend_from_slice(&bytes);
1209            if buf.windows(2).any(|w| w == b"\n\n") {
1210                break;
1211            }
1212        }
1213        String::from_utf8_lossy(&buf).to_string()
1214    }
1215
1216    #[test]
1217    fn index_ensure_body_parses_root_and_optional_extra_roots() {
1218        // Wire contract for the #460 daemon delegation endpoint: camelCase
1219        // `extraRoots`, optional and defaulting to empty. daemon_client serializes
1220        // exactly this shape, so a drift here silently breaks delegation.
1221        let full: IndexEnsureBody =
1222            serde_json::from_str(r#"{"root":"/a","extraRoots":["/b","/c"]}"#).unwrap();
1223        assert_eq!(full.root, "/a");
1224        assert_eq!(full.extra_roots, vec!["/b".to_string(), "/c".to_string()]);
1225
1226        let minimal: IndexEnsureBody = serde_json::from_str(r#"{"root":"/a"}"#).unwrap();
1227        assert_eq!(minimal.root, "/a");
1228        assert!(minimal.extra_roots.is_empty());
1229    }
1230
1231    #[tokio::test]
1232    async fn ipc_router_allows_local_tools_without_bearer_header() {
1233        let dir = tempfile::tempdir().expect("tempdir");
1234        let cfg = HttpServerConfig {
1235            project_root: dir.path().to_path_buf(),
1236            auth_token: Some("secret".to_string()),
1237            ..HttpServerConfig::default()
1238        };
1239        let app = build_app_router_with_auth(&cfg, false);
1240
1241        let body = json!({
1242            "name": "ctx_cache",
1243            "arguments": { "action": "stats" }
1244        })
1245        .to_string();
1246        let req = Request::builder()
1247            .method("POST")
1248            .uri("/v1/tools/call")
1249            .header("Host", "localhost")
1250            .header("Content-Type", "application/json")
1251            .body(Body::from(body))
1252            .expect("request");
1253
1254        let resp = app.oneshot(req).await.expect("resp");
1255        assert_ne!(resp.status(), StatusCode::UNAUTHORIZED);
1256    }
1257
1258    #[tokio::test]
1259    async fn auth_token_blocks_requests_without_bearer_header() {
1260        let dir = tempfile::tempdir().expect("tempdir");
1261        let root_str = dir.path().to_string_lossy().to_string();
1262        let service_project_root = root_str.clone();
1263        let service_factory = move || -> Result<LeanCtxServer, std::io::Error> {
1264            Ok(LeanCtxServer::new_shared_with_context(
1265                &service_project_root,
1266                "default",
1267                "default",
1268            ))
1269        };
1270        let cfg = StreamableHttpServerConfig::default()
1271            .with_stateful_mode(false)
1272            .with_json_response(true);
1273
1274        let mcp_http = StreamableHttpService::new(
1275            service_factory,
1276            Arc::new(
1277                rmcp::transport::streamable_http_server::session::local::LocalSessionManager::default(),
1278            ),
1279            cfg,
1280        );
1281
1282        let state = AppState {
1283            token: Some("secret".to_string()),
1284            concurrency: Arc::new(tokio::sync::Semaphore::new(4)),
1285            rate: Arc::new(RateLimiter::new(50, 100)),
1286            project_root: root_str.clone(),
1287            timeout: Duration::from_secs(30),
1288            server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1289        };
1290
1291        let app = Router::new()
1292            .fallback_service(mcp_http)
1293            .layer(middleware::from_fn_with_state(
1294                state.clone(),
1295                auth_middleware,
1296            ))
1297            .with_state(state);
1298
1299        let body = json!({
1300            "jsonrpc": "2.0",
1301            "id": 1,
1302            "method": "tools/list",
1303            "params": {}
1304        })
1305        .to_string();
1306
1307        let req = Request::builder()
1308            .method("POST")
1309            .uri("/")
1310            .header("Host", "localhost")
1311            .header("Accept", "application/json, text/event-stream")
1312            .header("Content-Type", "application/json")
1313            .body(Body::from(body))
1314            .expect("request");
1315
1316        let resp = app.clone().oneshot(req).await.expect("resp");
1317        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1318    }
1319
1320    #[tokio::test]
1321    async fn mcp_service_factory_isolates_per_client_state() {
1322        let dir = tempfile::tempdir().expect("tempdir");
1323        let root_str = dir.path().to_string_lossy().to_string();
1324
1325        // Mirrors the serve() setup: service_factory must create a fresh server per MCP session.
1326        let service_project_root = root_str.clone();
1327        let service_factory = move || -> Result<LeanCtxServer, std::convert::Infallible> {
1328            Ok(LeanCtxServer::new_shared_with_context(
1329                &service_project_root,
1330                "default",
1331                "default",
1332            ))
1333        };
1334
1335        let s1 = service_factory().expect("server 1");
1336        let s2 = service_factory().expect("server 2");
1337
1338        // If the two servers accidentally share the same Arc-backed fields, these writes would
1339        // clobber each other. This test stays independent of rmcp's InitializeRequestParams API.
1340        *s1.client_name.write().await = "client-a".to_string();
1341        *s2.client_name.write().await = "client-b".to_string();
1342
1343        let a = s1.client_name.read().await.clone();
1344        let b = s2.client_name.read().await.clone();
1345        assert_eq!(a, "client-a");
1346        assert_eq!(b, "client-b");
1347    }
1348
1349    #[tokio::test]
1350    async fn rate_limit_returns_429_when_exhausted() {
1351        let state = AppState {
1352            token: None,
1353            concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1354            rate: Arc::new(RateLimiter::new(1, 1)),
1355            project_root: ".".to_string(),
1356            timeout: Duration::from_secs(30),
1357            server: LeanCtxServer::new_shared_with_context(".", "default", "default"),
1358        };
1359
1360        let app = Router::new()
1361            .route("/limited", get(|| async { (StatusCode::OK, "ok\n") }))
1362            .layer(middleware::from_fn_with_state(
1363                state.clone(),
1364                rate_limit_middleware,
1365            ))
1366            .with_state(state);
1367
1368        let req1 = Request::builder()
1369            .method("GET")
1370            .uri("/limited")
1371            .header("Host", "localhost")
1372            .body(Body::empty())
1373            .expect("req1");
1374        let resp1 = app.clone().oneshot(req1).await.expect("resp1");
1375        assert_eq!(resp1.status(), StatusCode::OK);
1376
1377        let req2 = Request::builder()
1378            .method("GET")
1379            .uri("/limited")
1380            .header("Host", "localhost")
1381            .body(Body::empty())
1382            .expect("req2");
1383        let resp2 = app.clone().oneshot(req2).await.expect("resp2");
1384        assert_eq!(resp2.status(), StatusCode::TOO_MANY_REQUESTS);
1385    }
1386
1387    #[tokio::test]
1388    async fn audit_events_endpoint_returns_json() {
1389        let dir = tempfile::tempdir().expect("tempdir");
1390        let root_str = dir.path().to_string_lossy().to_string();
1391
1392        let state = AppState {
1393            token: None,
1394            concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1395            rate: Arc::new(RateLimiter::new(50, 100)),
1396            project_root: root_str.clone(),
1397            timeout: Duration::from_secs(30),
1398            server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1399        };
1400
1401        let app = Router::new()
1402            .route("/v1/audit/events", get(v1_audit_events))
1403            .with_state(state);
1404
1405        let req = Request::builder()
1406            .method("GET")
1407            .uri("/v1/audit/events?limit=10")
1408            .header("Host", "localhost")
1409            .body(Body::empty())
1410            .unwrap();
1411
1412        let resp = app.oneshot(req).await.unwrap();
1413        assert_eq!(resp.status(), StatusCode::OK);
1414
1415        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1416            .await
1417            .unwrap();
1418        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1419        assert!(json.get("cross_project_events").unwrap().is_array());
1420        assert!(json.get("audit_trail").unwrap().is_array());
1421    }
1422
1423    #[tokio::test]
1424    async fn capabilities_endpoint_returns_contract() {
1425        let dir = tempfile::tempdir().expect("tempdir");
1426        let root_str = dir.path().to_string_lossy().to_string();
1427
1428        let state = AppState {
1429            token: None,
1430            concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1431            rate: Arc::new(RateLimiter::new(50, 100)),
1432            project_root: root_str.clone(),
1433            timeout: Duration::from_secs(30),
1434            server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1435        };
1436
1437        let app = Router::new()
1438            .route("/v1/capabilities", get(v1_capabilities))
1439            .with_state(state);
1440
1441        let req = Request::builder()
1442            .method("GET")
1443            .uri("/v1/capabilities")
1444            .header("Host", "localhost")
1445            .body(Body::empty())
1446            .unwrap();
1447
1448        let resp = app.oneshot(req).await.unwrap();
1449        assert_eq!(resp.status(), StatusCode::OK);
1450
1451        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1452            .await
1453            .unwrap();
1454        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1455        assert_eq!(json["contract_version"], json!(1));
1456        assert!(json["tools"]["total"].as_u64().unwrap() > 0);
1457        assert!(json["features"]["compression"].as_bool().unwrap());
1458        assert!(json["contracts"].is_object());
1459    }
1460
1461    #[tokio::test]
1462    async fn openapi_endpoint_returns_spec() {
1463        let dir = tempfile::tempdir().expect("tempdir");
1464        let root_str = dir.path().to_string_lossy().to_string();
1465
1466        let state = AppState {
1467            token: None,
1468            concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1469            rate: Arc::new(RateLimiter::new(50, 100)),
1470            project_root: root_str.clone(),
1471            timeout: Duration::from_secs(30),
1472            server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1473        };
1474
1475        let app = Router::new()
1476            .route("/v1/openapi.json", get(v1_openapi))
1477            .with_state(state);
1478
1479        let req = Request::builder()
1480            .method("GET")
1481            .uri("/v1/openapi.json")
1482            .header("Host", "localhost")
1483            .body(Body::empty())
1484            .unwrap();
1485
1486        let resp = app.oneshot(req).await.unwrap();
1487        assert_eq!(resp.status(), StatusCode::OK);
1488
1489        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1490            .await
1491            .unwrap();
1492        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1493        assert_eq!(json["openapi"], json!("3.0.3"));
1494        assert!(json["paths"]["/v1/capabilities"]["get"].is_object());
1495        assert!(json["paths"]["/v1/openapi.json"]["get"].is_object());
1496    }
1497
1498    #[tokio::test]
1499    async fn events_endpoint_replays_tool_call_event() {
1500        use crate::core::context_os::{self, ContextEventKindV1};
1501
1502        let dir = tempfile::tempdir().expect("tempdir");
1503        std::fs::create_dir_all(dir.path().join(".git")).expect("git marker");
1504        std::fs::write(dir.path().join("a.txt"), "ok").expect("file");
1505        let root_str = dir.path().to_string_lossy().to_string();
1506
1507        let state = AppState {
1508            token: None,
1509            concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1510            rate: Arc::new(RateLimiter::new(50, 100)),
1511            project_root: root_str.clone(),
1512            timeout: Duration::from_secs(30),
1513            server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1514        };
1515
1516        let app = Router::new()
1517            .route("/v1/events", get(v1_events))
1518            .with_state(state);
1519
1520        // Directly append an event to the bus — no fire-and-forget timing dependency.
1521        let rt = context_os::runtime();
1522        rt.bus.append(
1523            "ws1",
1524            "ch1",
1525            &ContextEventKindV1::ToolCallRecorded,
1526            Some("test-agent"),
1527            json!({"tool": "ctx_session", "action": "status"}),
1528        );
1529
1530        let req = Request::builder()
1531            .method("GET")
1532            .uri("/v1/events?workspaceId=ws1&channelId=ch1&since=0&limit=1")
1533            .header("Host", "localhost")
1534            .header("Accept", "text/event-stream")
1535            .body(Body::empty())
1536            .expect("req");
1537        let resp = app.clone().oneshot(req).await.expect("events");
1538        assert_eq!(resp.status(), StatusCode::OK);
1539
1540        let msg = read_first_sse_message(resp.into_body()).await;
1541        assert!(msg.contains("event: tool_call_recorded"), "msg={msg:?}");
1542        assert!(msg.contains("\"ws1\""), "msg={msg:?}");
1543        assert!(msg.contains("\"ch1\""), "msg={msg:?}");
1544    }
1545}