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    let project_root = cfg.project_root.to_string_lossy().to_string();
968    let service_project_root = project_root.clone();
969    let service_factory = move || -> Result<LeanCtxServer, std::io::Error> {
970        Ok(LeanCtxServer::new_shared_with_context(
971            &service_project_root,
972            "default",
973            "default",
974        ))
975    };
976    let mcp_http = StreamableHttpService::new(
977        service_factory,
978        Arc::new(
979            rmcp::transport::streamable_http_server::session::local::LocalSessionManager::default(),
980        ),
981        cfg.mcp_http_config(),
982    );
983
984    let rest_server = LeanCtxServer::new_shared_with_context(&project_root, "default", "default");
985
986    let state = AppState {
987        token: cfg.effective_auth_token(),
988        concurrency: Arc::new(tokio::sync::Semaphore::new(cfg.max_concurrency.max(1))),
989        rate: Arc::new(RateLimiter::new(cfg.max_rps, cfg.rate_burst)),
990        project_root,
991        timeout: Duration::from_millis(cfg.request_timeout_ms.max(1)),
992        server: rest_server,
993    };
994
995    Router::new()
996        .route("/health", get(health))
997        .route("/v1/shutdown", axum::routing::post(v1_shutdown))
998        .route("/v1/index/ensure", axum::routing::post(v1_index_ensure))
999        .route("/v1/manifest", get(v1_manifest))
1000        .route("/v1/capabilities", get(v1_capabilities))
1001        .route("/v1/openapi.json", get(v1_openapi))
1002        .route("/v1/tools", get(v1_tools))
1003        .route("/v1/tools/call", axum::routing::post(v1_tool_call))
1004        .route("/v1/events", get(v1_events))
1005        .route(
1006            "/v1/context/summary",
1007            get(context_views::v1_context_summary),
1008        )
1009        .route("/v1/events/search", get(context_views::v1_events_search))
1010        .route("/v1/events/lineage", get(context_views::v1_event_lineage))
1011        .route("/v1/metrics", get(v1_metrics))
1012        .route("/v1/audit/events", get(v1_audit_events))
1013        .route("/v1/a2a/handoff", axum::routing::post(v1_a2a_handoff))
1014        .route("/v1/a2a/agent-card", get(v1_a2a_agent_card))
1015        .route("/.well-known/agent.json", get(v1_a2a_agent_card))
1016        .route("/.well-known/mcp-server.json", get(mcp_server_card))
1017        .route("/a2a", axum::routing::post(a2a_jsonrpc))
1018        .route(
1019            "/v1/agents/register",
1020            axum::routing::post(v1_agents_register),
1021        )
1022        .route(
1023            "/v1/agents/heartbeat",
1024            axum::routing::post(v1_agents_heartbeat),
1025        )
1026        .route("/v1/agents/list", get(v1_agents_list))
1027        .route(
1028            "/v1/agents/deregister",
1029            axum::routing::post(v1_agents_deregister),
1030        )
1031        .route("/v1/agents/events", get(v1_agents_events_sse))
1032        .fallback_service(mcp_http)
1033        .layer(axum::extract::DefaultBodyLimit::max(cfg.max_body_bytes))
1034        .layer(middleware::from_fn_with_state(
1035            state.clone(),
1036            rate_limit_middleware,
1037        ))
1038        .layer(middleware::from_fn_with_state(
1039            state.clone(),
1040            concurrency_middleware,
1041        ))
1042        .layer(middleware::from_fn_with_state(
1043            state.clone(),
1044            auth_middleware,
1045        ))
1046        .with_state(state)
1047}
1048
1049pub async fn serve(cfg: HttpServerConfig) -> Result<()> {
1050    crate::core::protocol::set_mcp_context(true);
1051    cfg.validate()?;
1052
1053    // Surface any path-jail relaxation inherited from the launch env or config,
1054    // so a loosened boundary is never silent (GH security audit, finding 3).
1055    crate::core::pathjail::warn_if_relaxed();
1056
1057    crate::core::plugins::PluginManager::init();
1058    crate::core::savings_autopush::spawn_if_enabled();
1059
1060    // Pre-warm the project indices in the background for this long-lived HTTP
1061    // server. The stdio path deliberately stays lazy — short-lived respawns must
1062    // not each pay a full graph + BM25 scan (#453) — but `serve` is a single,
1063    // persistent process: one background build gives the first heavy/search tool
1064    // call a warm index instead of racing a cold scan of a large project root
1065    // against the per-request timeout (the SDK-conformance regression, GL #395).
1066    // The build is deduped per root and idle CPU settles flat once it completes
1067    // (the memory guard backs off), so #453 idle hygiene is preserved.
1068    let warm_root = cfg.project_root.to_string_lossy().to_string();
1069    if !warm_root.is_empty() {
1070        crate::core::index_orchestrator::ensure_all_background(&warm_root);
1071    }
1072
1073    let addr: SocketAddr = format!("{}:{}", cfg.host, cfg.port)
1074        .parse()
1075        .context("invalid host/port")?;
1076
1077    let app = build_app_router(&cfg);
1078
1079    let listener = tokio::net::TcpListener::bind(addr)
1080        .await
1081        .with_context(|| format!("bind {addr}"))?;
1082
1083    tracing::info!(
1084        "lean-ctx Streamable HTTP server listening on http://{addr} (project_root={})",
1085        cfg.project_root.display()
1086    );
1087
1088    axum::serve(listener, app)
1089        .with_graceful_shutdown(async move {
1090            let _ = tokio::signal::ctrl_c().await;
1091        })
1092        .await
1093        .context("http server")?;
1094
1095    fire_session_end();
1096    Ok(())
1097}
1098
1099/// Fire the `on_session_end` plugin hook synchronously (best-effort, bounded by
1100/// each plugin's own timeout) so listeners run before the process exits. A
1101/// no-op unless a plugin declares the hook.
1102pub(crate) fn fire_session_end() {
1103    if crate::core::plugins::PluginManager::has_listener("on_session_end") {
1104        let _ = crate::core::plugins::PluginManager::fire_hook(
1105            &crate::core::plugins::executor::HookPoint::OnSessionEnd,
1106        );
1107    }
1108}
1109
1110#[cfg(windows)]
1111impl axum::serve::Listener for crate::ipc::NamedPipeListener {
1112    type Io = tokio::net::windows::named_pipe::NamedPipeServer;
1113    type Addr = String;
1114
1115    async fn accept(&mut self) -> (Self::Io, Self::Addr) {
1116        loop {
1117            match self.accept_pipe().await {
1118                Ok(pipe) => return (pipe, self.name().to_string()),
1119                Err(e) => {
1120                    tracing::error!("named pipe accept error: {e}");
1121                    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1122                }
1123            }
1124        }
1125    }
1126
1127    fn local_addr(&self) -> std::io::Result<Self::Addr> {
1128        Ok(self.name().to_string())
1129    }
1130}
1131
1132/// Serve the daemon over a platform-independent IPC channel (UDS on Unix,
1133/// Named Pipes on Windows).
1134pub async fn serve_ipc(cfg: HttpServerConfig, addr: crate::ipc::DaemonAddr) -> Result<()> {
1135    cfg.validate()?;
1136
1137    crate::core::plugins::PluginManager::init();
1138    crate::core::savings_autopush::spawn_if_enabled();
1139
1140    match addr {
1141        #[cfg(unix)]
1142        crate::ipc::DaemonAddr::Unix(ref path) => {
1143            let app = build_app_router(&cfg);
1144            let listener = crate::ipc::bind_listener(&addr)?;
1145
1146            tracing::info!(
1147                "lean-ctx daemon listening on {} (project_root={})",
1148                path.display(),
1149                cfg.project_root.display()
1150            );
1151
1152            axum::serve(listener, app.into_make_service())
1153                .with_graceful_shutdown(async move {
1154                    let _ = tokio::signal::ctrl_c().await;
1155                })
1156                .await
1157                .context("ipc server")?;
1158            Ok(())
1159        }
1160        #[cfg(windows)]
1161        crate::ipc::DaemonAddr::NamedPipe(ref name) => {
1162            let app = build_app_router(&cfg);
1163            let listener = crate::ipc::bind_listener(&addr)?;
1164
1165            tracing::info!(
1166                "lean-ctx daemon listening on {} (project_root={})",
1167                name,
1168                cfg.project_root.display()
1169            );
1170
1171            axum::serve(listener, app.into_make_service())
1172                .with_graceful_shutdown(async move {
1173                    let _ = tokio::signal::ctrl_c().await;
1174                })
1175                .await
1176                .context("ipc server")?;
1177            Ok(())
1178        }
1179    }
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184    use super::*;
1185    use axum::body::Body;
1186    use axum::http::Request;
1187    use futures::StreamExt;
1188    use rmcp::transport::{StreamableHttpServerConfig, StreamableHttpService};
1189    use serde_json::json;
1190    use tower::ServiceExt;
1191
1192    async fn read_first_sse_message(body: Body) -> String {
1193        let mut stream = body.into_data_stream();
1194        let mut buf: Vec<u8> = Vec::new();
1195        for _ in 0..32 {
1196            let next = tokio::time::timeout(Duration::from_secs(2), stream.next()).await;
1197            let Ok(Some(Ok(bytes))) = next else {
1198                break;
1199            };
1200            buf.extend_from_slice(&bytes);
1201            if buf.windows(2).any(|w| w == b"\n\n") {
1202                break;
1203            }
1204        }
1205        String::from_utf8_lossy(&buf).to_string()
1206    }
1207
1208    #[test]
1209    fn index_ensure_body_parses_root_and_optional_extra_roots() {
1210        // Wire contract for the #460 daemon delegation endpoint: camelCase
1211        // `extraRoots`, optional and defaulting to empty. daemon_client serializes
1212        // exactly this shape, so a drift here silently breaks delegation.
1213        let full: IndexEnsureBody =
1214            serde_json::from_str(r#"{"root":"/a","extraRoots":["/b","/c"]}"#).unwrap();
1215        assert_eq!(full.root, "/a");
1216        assert_eq!(full.extra_roots, vec!["/b".to_string(), "/c".to_string()]);
1217
1218        let minimal: IndexEnsureBody = serde_json::from_str(r#"{"root":"/a"}"#).unwrap();
1219        assert_eq!(minimal.root, "/a");
1220        assert!(minimal.extra_roots.is_empty());
1221    }
1222
1223    #[tokio::test]
1224    async fn auth_token_blocks_requests_without_bearer_header() {
1225        let dir = tempfile::tempdir().expect("tempdir");
1226        let root_str = dir.path().to_string_lossy().to_string();
1227        let service_project_root = root_str.clone();
1228        let service_factory = move || -> Result<LeanCtxServer, std::io::Error> {
1229            Ok(LeanCtxServer::new_shared_with_context(
1230                &service_project_root,
1231                "default",
1232                "default",
1233            ))
1234        };
1235        let cfg = StreamableHttpServerConfig::default()
1236            .with_stateful_mode(false)
1237            .with_json_response(true);
1238
1239        let mcp_http = StreamableHttpService::new(
1240            service_factory,
1241            Arc::new(
1242                rmcp::transport::streamable_http_server::session::local::LocalSessionManager::default(),
1243            ),
1244            cfg,
1245        );
1246
1247        let state = AppState {
1248            token: Some("secret".to_string()),
1249            concurrency: Arc::new(tokio::sync::Semaphore::new(4)),
1250            rate: Arc::new(RateLimiter::new(50, 100)),
1251            project_root: root_str.clone(),
1252            timeout: Duration::from_secs(30),
1253            server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1254        };
1255
1256        let app = Router::new()
1257            .fallback_service(mcp_http)
1258            .layer(middleware::from_fn_with_state(
1259                state.clone(),
1260                auth_middleware,
1261            ))
1262            .with_state(state);
1263
1264        let body = json!({
1265            "jsonrpc": "2.0",
1266            "id": 1,
1267            "method": "tools/list",
1268            "params": {}
1269        })
1270        .to_string();
1271
1272        let req = Request::builder()
1273            .method("POST")
1274            .uri("/")
1275            .header("Host", "localhost")
1276            .header("Accept", "application/json, text/event-stream")
1277            .header("Content-Type", "application/json")
1278            .body(Body::from(body))
1279            .expect("request");
1280
1281        let resp = app.clone().oneshot(req).await.expect("resp");
1282        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1283    }
1284
1285    #[tokio::test]
1286    async fn mcp_service_factory_isolates_per_client_state() {
1287        let dir = tempfile::tempdir().expect("tempdir");
1288        let root_str = dir.path().to_string_lossy().to_string();
1289
1290        // Mirrors the serve() setup: service_factory must create a fresh server per MCP session.
1291        let service_project_root = root_str.clone();
1292        let service_factory = move || -> Result<LeanCtxServer, std::convert::Infallible> {
1293            Ok(LeanCtxServer::new_shared_with_context(
1294                &service_project_root,
1295                "default",
1296                "default",
1297            ))
1298        };
1299
1300        let s1 = service_factory().expect("server 1");
1301        let s2 = service_factory().expect("server 2");
1302
1303        // If the two servers accidentally share the same Arc-backed fields, these writes would
1304        // clobber each other. This test stays independent of rmcp's InitializeRequestParams API.
1305        *s1.client_name.write().await = "client-a".to_string();
1306        *s2.client_name.write().await = "client-b".to_string();
1307
1308        let a = s1.client_name.read().await.clone();
1309        let b = s2.client_name.read().await.clone();
1310        assert_eq!(a, "client-a");
1311        assert_eq!(b, "client-b");
1312    }
1313
1314    #[tokio::test]
1315    async fn rate_limit_returns_429_when_exhausted() {
1316        let state = AppState {
1317            token: None,
1318            concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1319            rate: Arc::new(RateLimiter::new(1, 1)),
1320            project_root: ".".to_string(),
1321            timeout: Duration::from_secs(30),
1322            server: LeanCtxServer::new_shared_with_context(".", "default", "default"),
1323        };
1324
1325        let app = Router::new()
1326            .route("/limited", get(|| async { (StatusCode::OK, "ok\n") }))
1327            .layer(middleware::from_fn_with_state(
1328                state.clone(),
1329                rate_limit_middleware,
1330            ))
1331            .with_state(state);
1332
1333        let req1 = Request::builder()
1334            .method("GET")
1335            .uri("/limited")
1336            .header("Host", "localhost")
1337            .body(Body::empty())
1338            .expect("req1");
1339        let resp1 = app.clone().oneshot(req1).await.expect("resp1");
1340        assert_eq!(resp1.status(), StatusCode::OK);
1341
1342        let req2 = Request::builder()
1343            .method("GET")
1344            .uri("/limited")
1345            .header("Host", "localhost")
1346            .body(Body::empty())
1347            .expect("req2");
1348        let resp2 = app.clone().oneshot(req2).await.expect("resp2");
1349        assert_eq!(resp2.status(), StatusCode::TOO_MANY_REQUESTS);
1350    }
1351
1352    #[tokio::test]
1353    async fn audit_events_endpoint_returns_json() {
1354        let dir = tempfile::tempdir().expect("tempdir");
1355        let root_str = dir.path().to_string_lossy().to_string();
1356
1357        let state = AppState {
1358            token: None,
1359            concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1360            rate: Arc::new(RateLimiter::new(50, 100)),
1361            project_root: root_str.clone(),
1362            timeout: Duration::from_secs(30),
1363            server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1364        };
1365
1366        let app = Router::new()
1367            .route("/v1/audit/events", get(v1_audit_events))
1368            .with_state(state);
1369
1370        let req = Request::builder()
1371            .method("GET")
1372            .uri("/v1/audit/events?limit=10")
1373            .header("Host", "localhost")
1374            .body(Body::empty())
1375            .unwrap();
1376
1377        let resp = app.oneshot(req).await.unwrap();
1378        assert_eq!(resp.status(), StatusCode::OK);
1379
1380        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1381            .await
1382            .unwrap();
1383        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1384        assert!(json.get("cross_project_events").unwrap().is_array());
1385        assert!(json.get("audit_trail").unwrap().is_array());
1386    }
1387
1388    #[tokio::test]
1389    async fn capabilities_endpoint_returns_contract() {
1390        let dir = tempfile::tempdir().expect("tempdir");
1391        let root_str = dir.path().to_string_lossy().to_string();
1392
1393        let state = AppState {
1394            token: None,
1395            concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1396            rate: Arc::new(RateLimiter::new(50, 100)),
1397            project_root: root_str.clone(),
1398            timeout: Duration::from_secs(30),
1399            server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1400        };
1401
1402        let app = Router::new()
1403            .route("/v1/capabilities", get(v1_capabilities))
1404            .with_state(state);
1405
1406        let req = Request::builder()
1407            .method("GET")
1408            .uri("/v1/capabilities")
1409            .header("Host", "localhost")
1410            .body(Body::empty())
1411            .unwrap();
1412
1413        let resp = app.oneshot(req).await.unwrap();
1414        assert_eq!(resp.status(), StatusCode::OK);
1415
1416        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1417            .await
1418            .unwrap();
1419        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1420        assert_eq!(json["contract_version"], json!(1));
1421        assert!(json["tools"]["total"].as_u64().unwrap() > 0);
1422        assert!(json["features"]["compression"].as_bool().unwrap());
1423        assert!(json["contracts"].is_object());
1424    }
1425
1426    #[tokio::test]
1427    async fn openapi_endpoint_returns_spec() {
1428        let dir = tempfile::tempdir().expect("tempdir");
1429        let root_str = dir.path().to_string_lossy().to_string();
1430
1431        let state = AppState {
1432            token: None,
1433            concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1434            rate: Arc::new(RateLimiter::new(50, 100)),
1435            project_root: root_str.clone(),
1436            timeout: Duration::from_secs(30),
1437            server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1438        };
1439
1440        let app = Router::new()
1441            .route("/v1/openapi.json", get(v1_openapi))
1442            .with_state(state);
1443
1444        let req = Request::builder()
1445            .method("GET")
1446            .uri("/v1/openapi.json")
1447            .header("Host", "localhost")
1448            .body(Body::empty())
1449            .unwrap();
1450
1451        let resp = app.oneshot(req).await.unwrap();
1452        assert_eq!(resp.status(), StatusCode::OK);
1453
1454        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1455            .await
1456            .unwrap();
1457        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1458        assert_eq!(json["openapi"], json!("3.0.3"));
1459        assert!(json["paths"]["/v1/capabilities"]["get"].is_object());
1460        assert!(json["paths"]["/v1/openapi.json"]["get"].is_object());
1461    }
1462
1463    #[tokio::test]
1464    async fn events_endpoint_replays_tool_call_event() {
1465        use crate::core::context_os::{self, ContextEventKindV1};
1466
1467        let dir = tempfile::tempdir().expect("tempdir");
1468        std::fs::create_dir_all(dir.path().join(".git")).expect("git marker");
1469        std::fs::write(dir.path().join("a.txt"), "ok").expect("file");
1470        let root_str = dir.path().to_string_lossy().to_string();
1471
1472        let state = AppState {
1473            token: None,
1474            concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1475            rate: Arc::new(RateLimiter::new(50, 100)),
1476            project_root: root_str.clone(),
1477            timeout: Duration::from_secs(30),
1478            server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1479        };
1480
1481        let app = Router::new()
1482            .route("/v1/events", get(v1_events))
1483            .with_state(state);
1484
1485        // Directly append an event to the bus — no fire-and-forget timing dependency.
1486        let rt = context_os::runtime();
1487        rt.bus.append(
1488            "ws1",
1489            "ch1",
1490            &ContextEventKindV1::ToolCallRecorded,
1491            Some("test-agent"),
1492            json!({"tool": "ctx_session", "action": "status"}),
1493        );
1494
1495        let req = Request::builder()
1496            .method("GET")
1497            .uri("/v1/events?workspaceId=ws1&channelId=ch1&since=0&limit=1")
1498            .header("Host", "localhost")
1499            .header("Accept", "text/event-stream")
1500            .body(Body::empty())
1501            .expect("req");
1502        let resp = app.clone().oneshot(req).await.expect("events");
1503        assert_eq!(resp.status(), StatusCode::OK);
1504
1505        let msg = read_first_sse_message(resp.into_body()).await;
1506        assert!(msg.contains("event: tool_call_recorded"), "msg={msg:?}");
1507        assert!(msg.contains("\"ws1\""), "msg={msg:?}");
1508        assert!(msg.contains("\"ch1\""), "msg={msg:?}");
1509    }
1510}