Skip to main content

lean_ctx/http_server/
mod.rs

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