trusty_console/server/mod.rs
1//! Axum HTTP server for the trusty-console.
2//!
3//! Why: The console needs a lightweight HTTP server that serves the embedded
4//! SPA, a JSON API route for service status, and a reverse-proxy layer for
5//! all daemon sub-paths.
6//! What: Builds an axum `Router` with:
7//! - `GET /health` — liveness probe.
8//! - `GET /api/console/services` — return cached snapshot (background poll).
9//! - `GET /api/console/metrics/{analyze,memory,search,review,mpm}` — MCP-polled metrics.
10//! - `POST /api/webhooks/{source}` — GitHub webhook ingress: verify once,
11//! spool durably, relay over UDS (#5089 step 3, ADR-0034). Mounted only by
12//! [`build_router_with_webhooks`].
13//! - `GET /api/console/metrics/webhooks` — oldest-pending spool age as a red
14//! health state.
15//! - `DELETE /api/console/memory/palaces/{id}` — delete one palace via
16//! trusty-memory's `palace_delete` on its socket (#6360).
17//! - `DELETE /api/console/search/indexes/{id}` — delete one index via
18//! trusty-search's own `search.index.delete` on its socket (#6360, #6285).
19//! - `GET /api/console/metrics/analyze/indexes` — analyze index list via stdio MCP.
20//! - `GET /api/console/metrics/analyze/visualize?index=<id>` — graph+entities+clusters.
21//! - `…/api/console/sessions/*` — the single HTTP front door for the trusty-mpm
22//! session manager (#1222); handlers live in `crate::routes::sessions`.
23//! - `ANY /api/search/{*path}` — translate the request into a trusty-search
24//! RPC call on its socket (#6285); see `crate::search_uds`.
25//! - `ANY /api/{service}/{*path}` — reverse-proxy to live daemon via clean path
26//! (#1849 Phase 2); `{service}` ∈ {review, mpm, agents}.
27//! - `ANY /proxy/{daemon}/{*path}` — DEPRECATED alias; routes to the same
28//! handler with a trace-level deprecation note.
29//! - `GET /` and `GET /ui/*path` — serve the embedded Svelte SPA; the
30//! handlers live in `crate::console_ui` (#6285, 500-SLOC split).
31//! - `GET /tools/search/*path` — serve the embedded trusty-search SPA
32//! (#6155); see `crate::tools_ui`.
33//!
34//! All logs go to stderr; stdout is clean.
35//!
36//! Test: The `tests` module starts the router in a real axum test client.
37
38use std::collections::HashMap;
39use std::path::PathBuf;
40use std::sync::Arc;
41use std::time::Duration;
42
43use axum::{
44 Router,
45 extract::{Query, State},
46 http::StatusCode,
47 response::IntoResponse,
48 routing::{any, get, post},
49};
50use serde::Deserialize;
51use serde_json::json;
52use tower_http::cors::CorsLayer;
53use tower_http::trace::TraceLayer;
54
55use crate::connector::{ServiceConnector, ServiceInfo, ServiceStatus};
56use crate::mcp_handle::{McpHandleError, McpServiceHandle};
57use crate::metrics_poller::MetricsCache;
58use crate::poller::PollerCache;
59
60// ─── app state ───────────────────────────────────────────────────────────────
61
62/// Shared application state injected into every route handler.
63///
64/// Why: Connectors, the poller cache, metrics caches, and HTTP client are
65/// created once at startup and reused for every request so there is no per-
66/// request allocation. A separate `MetricsCache` is maintained for each
67/// stdio-MCP-polled service (analyze, memory, search, review) so they can be
68/// updated independently and served without coupling. `analyze_handle` is held
69/// in Arc so the on-demand visualize/index routes can call the analyze stdio MCP
70/// without going through the /proxy path.
71/// `mcp_handles` maps each service id to its `McpServiceHandle` so the
72/// services route can overlay the connector-reported status with the actual
73/// tools/list probe result (Degraded when `console_metrics` is absent).
74/// What: Wraps the connector list, poller cache, per-service metrics caches,
75/// reqwest client, the analyze MCP handle, and the full handle map in `Arc`s
76/// for cheap cloning.
77/// Test: Constructed in `build_router`; exercised by the integration tests.
78#[derive(Clone)]
79pub struct AppState {
80 connectors: Arc<Vec<Box<dyn ServiceConnector>>>,
81 poller_cache: PollerCache,
82 metrics_cache: MetricsCache,
83 memory_metrics_cache: MetricsCache,
84 search_metrics_cache: MetricsCache,
85 review_metrics_cache: MetricsCache,
86 /// trusty-mpm `console_metrics` cache (#1222). Populated by the background
87 /// poller; served by `GET /api/console/metrics/mpm`.
88 mpm_metrics_cache: MetricsCache,
89 http_client: Arc<reqwest::Client>,
90 /// The client the proxy uses for Server-Sent Events (#6155).
91 ///
92 /// Why: `http_client` sets a 30-second whole-request timeout, which is
93 /// right for a request/response API call and fatal for a stream that is
94 /// meant to stay open — the console-served search SPA opens
95 /// `/status/stream` on every page load, and under the shared client that
96 /// connection would be cut every 30 seconds and reopened by `EventSource`
97 /// forever.
98 /// What: identical to `http_client` except the total timeout is replaced by
99 /// a 60-second read timeout, which bounds an upstream that has gone silent
100 /// without bounding one that is still sending. Both search streams stay
101 /// well inside it: `/status/stream` pushes every 2 seconds and
102 /// `/reindex/stream` heartbeats every 20.
103 ///
104 /// The absent total deadline is why `proxy_handler` does not hand this
105 /// client's body straight to the caller. `Accept` is a caller claim, so a
106 /// response the upstream did not label `text/event-stream` is read under
107 /// `NON_STREAM_BODY_TIMEOUT` instead — otherwise any proxied GET could opt
108 /// into an unbounded connection by naming that Accept type.
109 /// Test: `proxy::routes::tests::test_wants_event_stream_*` covers which
110 /// client a request gets; the timeout itself is construction, exercised by
111 /// the live `/status/stream` smoke run recorded on #6155.
112 stream_client: Arc<reqwest::Client>,
113 /// Analyze stdio MCP handle — shared with the metrics poller so both the
114 /// background poll and on-demand route calls reuse the same child process.
115 analyze_handle: Arc<McpServiceHandle>,
116 /// All per-service MCP handles keyed by service id.
117 ///
118 /// Why: The services route reads each handle's degraded state to override
119 /// the connector-reported status when a reachable service is missing
120 /// `console_metrics`. Using a HashMap avoids adding individual Arc fields
121 /// for every future service.
122 /// What: Populated by `AppState::new`; read by `apply_handle_overrides`.
123 mcp_handles: Arc<HashMap<String, Arc<McpServiceHandle>>>,
124 /// Override for the trusty-search socket path (#6285).
125 ///
126 /// Why: `search_uds` and `detect::SearchConnector` resolve that path through
127 /// `trusty_common::daemon_socket_path`, which reads the process-global
128 /// `TRUSTY_DATA_DIR_OVERRIDE`. A test that redirected it would redirect five
129 /// sibling connectors running in the same binary at the same time, so the
130 /// override is carried here instead — the same argument
131 /// `detect::AnalyzeConnector::with_socket` records.
132 /// What: `None` in production, which resolves the real path.
133 /// Test: `tests/search_uds_bridge.rs` sets it on every case.
134 pub(crate) search_socket: Option<Arc<PathBuf>>,
135}
136
137impl AppState {
138 /// Create a new `AppState` from a list of connectors.
139 ///
140 /// Why: Lets tests inject a custom connector list and fresh caches.
141 /// What: Wraps `connectors` in `Arc`; initialises empty `PollerCache`,
142 /// three `MetricsCache` instances (analyze / memory / search), and a
143 /// `reqwest::Client` with idle-connection pooling disabled (#1984 — see the
144 /// builder comment below). Creates the analyze stdio MCP handle that is
145 /// shared between the background metrics poller and on-demand routes.
146 /// Populates `mcp_handles` with all three per-service handles so the
147 /// services route can read their degraded state.
148 /// Test: Used in `build_router` and directly in `tests`.
149 pub fn new(connectors: Vec<Box<dyn ServiceConnector>>) -> Self {
150 // Why pool_max_idle_per_host(0): the proxy client must survive an upstream
151 // daemon restart (#1984). With the default keep-alive pool, the FIRST
152 // proxied request after an upstream restart reuses a stale idle connection
153 // to the now-dead process and fails — an instant RST → 502, a half-open
154 // hang → 30s-timeout → 502, or a partial write the restarted daemon
155 // rejects → 500 — even though a direct curl (which never pools across
156 // invocations) always opens a fresh connection and succeeds. reqwest does
157 // NOT retry a non-idempotent POST on a broken pooled connection, so the
158 // failure is surfaced to the caller (e.g. `tm session new`). Disabling
159 // idle-connection reuse forces every proxied request to open a fresh
160 // connection to whatever process currently owns the port, eliminating the
161 // stale-reuse failure at the root. Loopback connect cost is negligible.
162 // #6360: reqwest follows up to 10 redirects by default, and every
163 // loopback check in this crate — the proxy's `is_local_upstream`, the
164 // delete routes' reuse of it — validates only the URL it was handed. A
165 // 3xx from an upstream would re-issue the request, body and method
166 // intact, at whatever host the `Location` names, which for a DELETE
167 // means a destructive call to an address nothing checked. Refusing to
168 // follow leaves the 3xx as the response: the proxy hands it to the
169 // browser (which is what a reverse proxy should do with a redirect the
170 // upstream chose) and the delete routes read it as a non-2xx refusal.
171 // Nothing in this crate relied on following one.
172 let client = reqwest::Client::builder()
173 .timeout(Duration::from_secs(30))
174 .pool_max_idle_per_host(0)
175 .redirect(reqwest::redirect::Policy::none())
176 .build()
177 .expect("reqwest client init");
178 // #6155: same connection policy, no whole-request deadline — see the
179 // `stream_client` field doc.
180 let stream_client = reqwest::Client::builder()
181 .read_timeout(Duration::from_secs(60))
182 .pool_max_idle_per_host(0)
183 .redirect(reqwest::redirect::Policy::none())
184 .build()
185 .expect("reqwest stream client init");
186 let analyze_handle = Arc::new(McpServiceHandle::new(
187 "trusty-analyze",
188 vec!["mcp".to_string()],
189 ));
190 let memory_handle = Arc::new(McpServiceHandle::new(
191 "trusty-memory",
192 vec!["serve".to_string(), "--stdio".to_string()],
193 ));
194 let search_handle = Arc::new(McpServiceHandle::new(
195 "trusty-search",
196 vec!["serve".to_string()],
197 ));
198 // Why: trusty-review's stdio MCP mode is `serve --stdio` (see ServeArgs
199 // in commands/serve.rs). This is the canonical command the console spawns
200 // to poll `console_metrics` without requiring the HTTP daemon to be running.
201 let review_handle = Arc::new(McpServiceHandle::new(
202 "trusty-review",
203 vec!["serve".to_string(), "--stdio".to_string()],
204 ));
205 // Why: trusty-mpm's stdio MCP mode is `serve --stdio` (the #1221 bridge
206 // that auto-starts the durable daemon and forwards JSON-RPC to its
207 // loopback POST /rpc). The console spawns this to render the Sessions tab
208 // natively (#1222) without ever touching the daemon's HTTP port (#1104).
209 let mpm_handle = Arc::new(McpServiceHandle::new(
210 "trusty-mpm",
211 vec!["serve".to_string(), "--stdio".to_string()],
212 ));
213 let mut handles: HashMap<String, Arc<McpServiceHandle>> = HashMap::new();
214 handles.insert("trusty-analyze".to_string(), Arc::clone(&analyze_handle));
215 handles.insert("trusty-memory".to_string(), Arc::clone(&memory_handle));
216 handles.insert("trusty-search".to_string(), Arc::clone(&search_handle));
217 handles.insert("trusty-review".to_string(), Arc::clone(&review_handle));
218 handles.insert("trusty-mpm".to_string(), Arc::clone(&mpm_handle));
219 Self {
220 connectors: Arc::new(connectors),
221 poller_cache: PollerCache::new(),
222 metrics_cache: MetricsCache::new(),
223 memory_metrics_cache: MetricsCache::new(),
224 search_metrics_cache: MetricsCache::new(),
225 review_metrics_cache: MetricsCache::new(),
226 mpm_metrics_cache: MetricsCache::new(),
227 http_client: Arc::new(client),
228 stream_client: Arc::new(stream_client),
229 analyze_handle,
230 mcp_handles: Arc::new(handles),
231 search_socket: None,
232 }
233 }
234
235 /// Access the per-service MCP handle map.
236 ///
237 /// Why: The services route reads handles from this map to overlay connector
238 /// statuses with the tools/list probe result.
239 /// What: Returns a clone of the `Arc<HashMap>` (cheap).
240 /// Test: Used by `apply_handle_overrides` and the services handler.
241 pub fn mcp_handles(&self) -> Arc<HashMap<String, Arc<McpServiceHandle>>> {
242 Arc::clone(&self.mcp_handles)
243 }
244
245 /// Access the shared analyze MCP handle.
246 ///
247 /// Why: On-demand routes (`/api/console/metrics/analyze/indexes`,
248 /// `/api/console/metrics/analyze/visualize`) call the analyze stdio MCP
249 /// without touching the analyze daemon HTTP directly (architecture: console
250 /// is a stdio MCP client only, per #1104).
251 /// What: Returns a clone of the `Arc<McpServiceHandle>` (cheap).
252 /// Test: Exercised by the analyze index and visualize route tests.
253 pub fn analyze_handle(&self) -> Arc<McpServiceHandle> {
254 Arc::clone(&self.analyze_handle)
255 }
256
257 /// Access the shared connector list.
258 ///
259 /// Why: The background poller and the fallback `spawn_blocking` path both
260 /// need the connector list.
261 /// What: Returns a clone of the `Arc` (cheap).
262 /// Test: Used by `run_serve` in `main.rs`.
263 pub fn connectors(&self) -> Arc<Vec<Box<dyn ServiceConnector>>> {
264 Arc::clone(&self.connectors)
265 }
266
267 /// Access the background poll cache.
268 ///
269 /// Why: Routes read from the cache; the background task writes to it.
270 /// What: Returns a clone of the `PollerCache` handle (cheap — it's an Arc).
271 /// Test: Used by `services_handler` and `proxy_handler`.
272 pub fn poller_cache(&self) -> &PollerCache {
273 &self.poller_cache
274 }
275
276 /// Access the metrics cache for the trusty-analyze stdio MCP poller.
277 ///
278 /// Why: The metrics poller writes `ConsoleMetricsReport`s here; the
279 /// `/api/console/metrics/analyze` route reads from it.
280 /// What: Returns a reference to the `MetricsCache` handle.
281 /// Test: `test_metrics_analyze_route_cold_cache_returns_503`.
282 pub fn metrics_cache(&self) -> &MetricsCache {
283 &self.metrics_cache
284 }
285
286 /// Access the metrics cache for the trusty-memory stdio MCP poller.
287 ///
288 /// Why: Separate cache per service so memory and analyze reports can be
289 /// updated and served independently.
290 /// What: Returns a reference to the `MetricsCache` handle for memory.
291 /// Test: `test_metrics_memory_route_cold_cache_returns_503`.
292 pub fn memory_metrics_cache(&self) -> &MetricsCache {
293 &self.memory_metrics_cache
294 }
295
296 /// Access the metrics cache for the trusty-search stdio MCP poller.
297 ///
298 /// Why: Separate cache per service so search and analyze reports can be
299 /// updated and served independently.
300 /// What: Returns a reference to the `MetricsCache` handle for search.
301 /// Test: `test_metrics_search_route_cold_cache_returns_503`.
302 pub fn search_metrics_cache(&self) -> &MetricsCache {
303 &self.search_metrics_cache
304 }
305
306 /// Access the metrics cache for the trusty-review stdio MCP poller.
307 ///
308 /// Why: Separate cache per service so review reports can be updated and
309 /// served independently from the other service caches.
310 /// What: Returns a reference to the `MetricsCache` handle for review.
311 /// Test: `test_metrics_review_route_cold_cache_returns_503`.
312 pub fn review_metrics_cache(&self) -> &MetricsCache {
313 &self.review_metrics_cache
314 }
315
316 /// Access the metrics cache for the trusty-mpm stdio MCP poller (#1222).
317 ///
318 /// Why: separate cache per service so the mpm session/supervisor report can
319 /// be updated and served independently from the other service caches.
320 /// What: returns a reference to the `MetricsCache` handle for mpm.
321 /// Test: `test_metrics_mpm_route_cold_cache_returns_503`.
322 pub fn mpm_metrics_cache(&self) -> &MetricsCache {
323 &self.mpm_metrics_cache
324 }
325
326 /// Access the shared `reqwest::Client`.
327 ///
328 /// Why: Re-using one client enables connection pooling across proxy requests.
329 /// What: Returns a clone of the `Arc<reqwest::Client>` (cheap).
330 /// Test: Used by `proxy_handler`.
331 pub fn http_client(&self) -> Arc<reqwest::Client> {
332 Arc::clone(&self.http_client)
333 }
334
335 /// The client to proxy a Server-Sent Events request with (#6155).
336 pub fn stream_client(&self) -> Arc<reqwest::Client> {
337 Arc::clone(&self.stream_client)
338 }
339}
340
341// ─── router ──────────────────────────────────────────────────────────────────
342
343/// Build the axum `Router` with all routes wired, trusting only loopback as
344/// the write-origin self-origin.
345///
346/// Why: Extracting the router into its own function allows both `main` and the
347/// test harness to share the same routing configuration without running a real
348/// TCP server. This loopback-only entry point is what every existing test and
349/// `Local`/`Explicit` (non-Tailscale) bind mode use; Tailscale deployments use
350/// [`build_router_with_self_origins`] instead so their own bind address is
351/// also trusted (#3269).
352/// What: Returns a `Router<()>` with CORS, tracing middleware, and all routes.
353/// Test: Called from `tests::test_services_route_returns_json` below.
354pub fn build_router(state: AppState) -> Router {
355 build_router_with_self_origins(state, crate::routes::origin_guard::SelfOrigins::default())
356}
357
358/// Build the router with the webhook ingress mounted (#5089 step 3).
359///
360/// Why: the ingress owns a spool directory, so constructing it can fail — and
361/// it must fail loudly at startup rather than silently leaving
362/// `/api/webhooks/{source}` unrouted, which would turn every delivery into a
363/// `404` GitHub records as a failure nobody looks at. Keeping it a separate
364/// parameter lets `run_serve` do that fallible construction once while the
365/// existing infallible `build_router` call sites (and every test that does not
366/// exercise webhooks) stay unchanged.
367/// What: identical to [`build_router_with_self_origins`], plus
368/// `POST /api/webhooks/{source}` and `GET /api/console/metrics/webhooks`, both
369/// carrying `WebhookIngress` as their own state.
370/// Test: the `route_*` and `metrics_route_*` cases in `webhook/tests.rs`.
371pub fn build_router_with_webhooks(
372 state: AppState,
373 self_origins: crate::routes::origin_guard::SelfOrigins,
374 ingress: crate::webhook::WebhookIngress,
375) -> Router {
376 build_router_inner(state, self_origins, Some(ingress))
377}
378
379/// Build the axum `Router` with all routes wired, additionally trusting the
380/// given bind-derived, non-loopback self-origins for the write-origin guard.
381///
382/// Why: #3269 — in Tailscale bind mode the console's own write UI is served
383/// from a non-loopback address; the guard must trust that exact address
384/// (derived from the server's actually-resolved bind addresses) without
385/// opening up to arbitrary remote origins. Splitting this out from
386/// `build_router` keeps every existing (loopback-only) call site and test
387/// unchanged.
388/// What: Identical router to `build_router`, except the write-origin guard
389/// (see below) is constructed with `self_origins` instead of the default
390/// empty set.
391/// Test: `server/tests.rs` tests `proxy_route_allows_self_origin_write` /
392/// `proxy_route_rejects_cross_origin_write`; `bind.rs`/`lib.rs` wire the real
393/// resolved addresses in `run_serve`.
394pub fn build_router_with_self_origins(
395 state: AppState,
396 self_origins: crate::routes::origin_guard::SelfOrigins,
397) -> Router {
398 build_router_inner(state, self_origins, None)
399}
400
401/// The one router definition both public builders delegate to, so the mounted
402/// route set cannot drift between them.
403fn build_router_inner(
404 state: AppState,
405 self_origins: crate::routes::origin_guard::SelfOrigins,
406 webhook: Option<crate::webhook::WebhookIngress>,
407) -> Router {
408 let core = Router::new()
409 .route("/health", get(health_handler))
410 .route("/api/console/services", get(services_handler))
411 .route("/api/console/metrics/analyze", get(metrics_analyze_handler))
412 .route("/api/console/metrics/memory", get(metrics_memory_handler))
413 .route("/api/console/metrics/search", get(metrics_search_handler))
414 .route("/api/console/metrics/review", get(metrics_review_handler))
415 .route("/api/console/metrics/mpm", get(metrics_mpm_handler))
416 // ── trusty-mpm session-manager surface (#1222: P2 tab + P3 front door) ──
417 // The console is the SINGLE HTTP front door for the session REST API;
418 // every handler calls a trusty-mpm MCP tool via the stdio bridge — never
419 // the daemon's HTTP port (#1104).
420 //
421 // Route precedence (verified, NOT declaration-order dependent): axum 0.8
422 // routes via matchit 0.8, which prioritises a literal/static path segment
423 // over a `{param}` capture at the same position regardless of the order
424 // routes are added. So `/sessions/supervisor` and
425 // `/sessions/supervisor/auto-resume` always win over `/sessions/{id}` —
426 // a request for `…/supervisor` reaches `supervisor_handler`, never
427 // `get_handler` with id="supervisor". This is asserted directly by
428 // `routes::sessions::tests::supervisor_route_is_not_shadowed_by_id_capture`
429 // and `…::auto_resume_route_is_not_shadowed`.
430 .route(
431 "/api/console/sessions",
432 get(crate::routes::sessions::list_handler).post(crate::routes::sessions::new_handler),
433 )
434 .route(
435 "/api/console/sessions/supervisor",
436 get(crate::routes::sessions::supervisor_handler),
437 )
438 .route(
439 "/api/console/sessions/supervisor/auto-resume",
440 axum::routing::post(crate::routes::sessions::auto_resume_handler),
441 )
442 .route(
443 "/api/console/sessions/{id}",
444 get(crate::routes::sessions::get_handler)
445 .delete(crate::routes::sessions::decommission_handler),
446 )
447 .route(
448 "/api/console/sessions/{id}/activity",
449 get(crate::routes::sessions::activity_handler),
450 )
451 .route(
452 "/api/console/sessions/{id}/stop",
453 axum::routing::post(crate::routes::sessions::stop_handler),
454 )
455 .route(
456 "/api/console/sessions/{id}/resume",
457 axum::routing::post(crate::routes::sessions::resume_handler),
458 )
459 // #1220 Config tab: read/write the `~/.trusty-tools/trusty-mpm/config.yaml`
460 // convention via the trusty-mpm `config_read` / `config_write` MCP tools.
461 // The POST is a state-changing write; the router-wide origin guard
462 // (see the `.layer()` call near the bottom of this router) covers it.
463 .route(
464 "/api/console/config/mpm",
465 get(crate::routes::config::get_handler).post(crate::routes::config::post_handler),
466 )
467 // #6360: operator-driven deletion of one palace / one index. Both call
468 // the owning daemon's existing teardown and report what it actually did
469 // — the console implements no deletion of its own. The router-wide
470 // origin guard below covers them, as it does every other write route.
471 .route(
472 "/api/console/memory/palaces/{id}",
473 axum::routing::delete(crate::routes::deletes::delete_palace_handler),
474 )
475 .route(
476 "/api/console/search/indexes/{id}",
477 axum::routing::delete(crate::routes::deletes::delete_index_handler),
478 )
479 // #6371: batch prune of stale index registrations, and palace
480 // compaction. `prune-indexes` is not `indexes/prune` because a static
481 // segment beside `indexes/{id}` would shadow an index named `prune`.
482 .route(
483 "/api/console/search/prune-indexes",
484 post(crate::routes::cleanup::prune_indexes_handler),
485 )
486 .route(
487 "/api/console/memory/palaces/{id}/compact",
488 post(crate::routes::cleanup::compact_palace_handler),
489 )
490 // Analyze on-demand routes — call the analyze stdio MCP directly (no /proxy).
491 .route(
492 "/api/console/metrics/analyze/indexes",
493 get(analyze_indexes_handler),
494 )
495 .route(
496 "/api/console/metrics/analyze/visualize",
497 get(analyze_visualize_handler),
498 )
499 // #6285: `search` is NOT a reverse-proxy row any more. trusty-search
500 // moved onto a Unix socket (ADR-0032) and stopped writing the
501 // `http_addr` file the proxy resolves a base URL from, so this literal
502 // route takes the prefix and translates each request into an RPC call.
503 // matchit prefers the static `search` segment over the `{service}`
504 // capture below, so the two cannot collide.
505 .route(
506 "/api/search/{*path}",
507 any(crate::search_uds::routes::search_api_handler),
508 )
509 .route(
510 "/proxy/search/{*path}",
511 any(crate::search_uds::routes::deprecated_search_api_handler),
512 )
513 // Primary reverse-proxy: /api/{service}/{*path} (#1849 Phase 2).
514 // {service} ∈ {review, mpm, agents}.
515 // No collision with /api/console/*: axum (matchit 0.8) routes literal
516 // segments before wildcard captures, so /api/console/* always wins.
517 // The proxy handler also rejects service_key == "console" explicitly as // pragma: allowlist secret
518 // a routing-independent second layer of defence.
519 .route("/api/{service}/{*path}", any(crate::proxy::proxy_handler))
520 // Deprecated alias: /proxy/{daemon}/{*path} → same handler with a trace log.
521 // Kept for backward compatibility; callers should migrate to /api/{service}/*.
522 .route(
523 "/proxy/{daemon}/{*path}",
524 any(crate::proxy::deprecated_proxy_handler),
525 )
526 // #6155: the trusty-search SPA, served from this binary under
527 // /tools/search/. Its API calls resolve to /api/search/*, which the
528 // proxy route above forwards — so the dashboard keeps working once
529 // trusty-search drops its own HTTP surface (#6285, ADR-0032).
530 .route("/tools/search", get(crate::tools_ui::search_ui_redirect))
531 .route("/tools/search/", get(crate::tools_ui::search_ui_index))
532 .route(
533 "/tools/search/{*path}",
534 get(crate::tools_ui::search_ui_asset),
535 )
536 .route("/", get(crate::console_ui::spa_index_handler))
537 .route("/ui", get(crate::console_ui::spa_index_handler))
538 .route("/ui/", get(crate::console_ui::spa_index_handler))
539 .route("/ui/{*path}", get(crate::console_ui::spa_asset_handler))
540 .with_state(state);
541
542 // Webhook ingress (#5089 step 3, ADR-0034). Merged as its own state-typed
543 // sub-router. `/api/webhooks/{source}` cannot be shadowed by the
544 // `/api/{service}/{*path}` proxy above: matchit 0.8 prefers a static
545 // segment over a `{param}` capture at the same position, so `webhooks`
546 // wins regardless of declaration order — the same precedence rule the
547 // `/api/console/*` routes already rely on.
548 let router = match webhook {
549 Some(ingress) => core.merge(
550 Router::new()
551 .route(
552 "/api/webhooks/{source}",
553 axum::routing::post(crate::webhook::webhook_handler),
554 )
555 .route(
556 "/api/console/metrics/webhooks",
557 get(crate::webhook::metrics_webhooks_handler),
558 )
559 .with_state(ingress)
560 // axum's DefaultBodyLimit is 2 MiB, which silently 413s a real
561 // delivery before the handler runs: no spool entry, no metric,
562 // no ack — the exact invisible drop this route exists to
563 // prevent. GitHub payloads are legal to 25 MB and `push` /
564 // `pull_request` bodies routinely pass 2 MiB. Scoped to this
565 // sub-router so the proxy and SPA routes keep the default.
566 // (`trusty-search` sets 64 MiB the same way, at
567 // `service/server/mod.rs:251`.)
568 .layer(axum::extract::DefaultBodyLimit::max(
569 crate::webhook::MAX_WEBHOOK_BODY_BYTES,
570 )),
571 ),
572 None => core,
573 };
574
575 router
576 // Same-origin guard for ALL destructive write routes, applied
577 // router-wide (#3268 fix). The console serves a permissive CORS
578 // policy (open reads), so without this guard any web page the
579 // operator visited could fire a cross-origin `fetch` and
580 // spawn/stop/decommission sessions, or — since this is a plain
581 // `.layer()`, not `route_layer` — reach destructive daemon endpoints
582 // through the reverse-proxy routes above (`/api/{service}/{*path}`,
583 // `/proxy/{daemon}/{*path}`), which a route-scoped `route_layer`
584 // placed earlier in the chain would miss entirely (the #3268 root
585 // cause). The middleware is method-aware — it only blocks
586 // state-changing methods whose `Origin` header is present and
587 // neither loopback nor a trusted self-origin, so GET reads (and the
588 // read-only daemon proxy traffic) pass through untouched.
589 .layer(axum::middleware::from_fn_with_state(
590 self_origins,
591 crate::routes::origin_guard::guard_write_origin,
592 ))
593 .layer(CorsLayer::permissive())
594 .layer(TraceLayer::new_for_http())
595}
596
597// ─── handlers ────────────────────────────────────────────────────────────────
598
599/// `GET /health` — liveness probe.
600///
601/// Why: Required by process monitors and the `trusty-console status` CLI
602/// subcommand. Returns a minimal JSON body so callers can confirm the server
603/// is up and which version is running.
604/// What: Returns `{"status":"ok","version":"<CARGO_PKG_VERSION>"}`.
605/// Test: Tested by `test_health_route` below.
606async fn health_handler() -> impl IntoResponse {
607 axum::Json(json!({
608 "status": "ok",
609 "version": env!("CARGO_PKG_VERSION"),
610 }))
611}
612
613/// Apply per-service MCP handle state on top of connector-reported statuses.
614///
615/// Why: The connector `detect()` path (TCP probe / `which`) can only report
616/// `Running`, `Available`, or `Absent`. It has no knowledge of the MCP
617/// `tools/list` probe result. When a service is reachable but the
618/// `console_metrics` tool is absent (`HandleState::Degraded`), the connector
619/// still reports `Running` or `Available` — the UI incorrectly shows a healthy
620/// badge. This function overlays the handle's known state: if a handle is
621/// Degraded, the corresponding `ServiceInfo` is updated in-place to
622/// `status = Degraded` and `hint = DEGRADED_HINT`. If a handle is Connected, the
623/// daemon version from the `initialize` response is surfaced (unless the connector
624/// already reported a version from the HTTP `/health` endpoint).
625/// What: Iterates `infos` in place; for each entry looks up the matching handle
626/// by `id`. If `handle.degraded_hint()` returns `Some(hint)` and the current
627/// status is NOT already `Absent`, sets `status = Degraded` and `hint = Some`.
628/// If `info.version` is `None` and `handle.daemon_version()` returns `Some`,
629/// sets `info.version` from the MCP `serverInfo.version`.
630/// A process-down (`Absent`) service is never overridden — only reachable ones.
631/// Skipping only `Absent` is safe: `Available` handles always return `None`
632/// from `degraded_hint` (no tools/list probe runs until the first poll), so
633/// they pass through unchanged.
634/// Test: `test_services_route_handle_degraded_overlay` and
635/// `test_services_route_daemon_version_overlay` below.
636async fn apply_handle_overrides(
637 infos: &mut [ServiceInfo],
638 handles: &HashMap<String, Arc<McpServiceHandle>>,
639) {
640 for info in infos.iter_mut() {
641 if info.status == ServiceStatus::Absent {
642 continue;
643 }
644 if let Some(handle) = handles.get(&info.id) {
645 if let Some(hint) = handle.degraded_hint().await {
646 info.status = ServiceStatus::Degraded;
647 info.hint = Some(hint);
648 }
649 // Surface the MCP daemon version when the connector hasn't
650 // already provided one (e.g. when the HTTP daemon isn't running
651 // but the stdio MCP process is up and has responded to initialize).
652 if info.version.is_none()
653 && let Some(ver) = handle.daemon_version().await
654 {
655 info.version = Some(ver);
656 }
657 }
658 }
659}
660
661/// `GET /api/console/services` — return cached snapshot of all services.
662///
663/// Why: The Svelte SPA fetches this endpoint on load to render service cards.
664/// With the background poller in place the response is instant (no per-
665/// request TCP probes).
666/// What: Reads the latest `CachedSnapshot` from the `PollerCache`. If the first
667/// poll has not completed yet, falls back to a synchronous on-demand detection
668/// so the UI always gets data (the first-boot latency is acceptable; after that
669/// every response is cache-backed). A panic in the fallback blocking task
670/// surfaces as HTTP 500 rather than an empty 200.
671/// After obtaining the base service list (from cache or fallback), applies
672/// per-service handle degraded overrides via `apply_handle_overrides` so
673/// reachable services missing `console_metrics` surface as `status: degraded`,
674/// then sorts the list with `detect::order_for_display` so the Overview grid
675/// leads with the services that are actually live (#6370).
676/// Test: `test_services_route_returns_json`,
677/// `test_services_handler_returns_500_on_panic`,
678/// `test_services_route_handle_degraded_overlay`, and
679/// `test_services_route_orders_running_before_absent` below.
680async fn services_handler(State(state): State<AppState>) -> axum::response::Response {
681 let handles = state.mcp_handles();
682
683 if let Some(snap) = state.poller_cache().snapshot().await {
684 let mut services = snap.services;
685 apply_handle_overrides(&mut services, &handles).await;
686 // #6370: sort AFTER the overrides so a service demoted to Degraded here
687 // ranks as degraded, not as the Running the poller recorded.
688 crate::detect::order_for_display(&mut services);
689 return axum::Json(services).into_response();
690 }
691
692 // First-boot fallback: run a one-shot detection synchronously.
693 let connectors = state.connectors();
694 match tokio::task::spawn_blocking(move || {
695 connectors.iter().map(|c| c.detect()).collect::<Vec<_>>()
696 })
697 .await
698 {
699 Ok(mut infos) => {
700 apply_handle_overrides(&mut infos, &handles).await;
701 crate::detect::order_for_display(&mut infos); // #6370
702 axum::Json(infos).into_response()
703 }
704 Err(e) => {
705 tracing::error!("service detection task panicked: {e}");
706 StatusCode::INTERNAL_SERVER_ERROR.into_response()
707 }
708 }
709}
710
711/// `GET /api/console/metrics/analyze` — return the latest metrics report.
712///
713/// Why: Surfaces trusty-analyze health/metrics to the SPA without per-request
714/// MCP calls (the background poller keeps the cache warm).
715/// What: Returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when
716/// no poll has completed yet (binary absent or first boot).
717/// Test: `test_metrics_analyze_route_cold_cache_returns_503` below.
718async fn metrics_analyze_handler(State(state): State<AppState>) -> axum::response::Response {
719 match state.metrics_cache().get().await {
720 Some(report) => axum::Json(report).into_response(),
721 None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
722 }
723}
724
725/// `GET /api/console/metrics/memory` — return the latest memory metrics report.
726///
727/// Why: Surfaces trusty-memory health/metrics to the SPA without per-request
728/// MCP calls (the background poller keeps the cache warm).
729/// What: Returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when
730/// no poll has completed yet (binary absent or first boot).
731/// Test: `test_metrics_memory_route_cold_cache_returns_503` below.
732async fn metrics_memory_handler(State(state): State<AppState>) -> axum::response::Response {
733 match state.memory_metrics_cache().get().await {
734 Some(report) => axum::Json(report).into_response(),
735 None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
736 }
737}
738
739/// `GET /api/console/metrics/search` — return the latest search metrics report.
740///
741/// Why: Surfaces trusty-search health/metrics to the SPA without per-request
742/// MCP calls (the background poller keeps the cache warm).
743/// What: Returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when
744/// no poll has completed yet (binary absent or first boot).
745/// Test: `test_metrics_search_route_cold_cache_returns_503` below.
746async fn metrics_search_handler(State(state): State<AppState>) -> axum::response::Response {
747 match state.search_metrics_cache().get().await {
748 Some(report) => axum::Json(report).into_response(),
749 None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
750 }
751}
752
753/// `GET /api/console/metrics/review` — return the latest review metrics report.
754///
755/// Why: Surfaces trusty-review health/metrics to the SPA without per-request
756/// MCP calls (the background poller keeps the cache warm).
757/// What: Returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when
758/// no poll has completed yet (binary absent or first boot).
759/// Test: `test_metrics_review_route_cold_cache_returns_503` below.
760async fn metrics_review_handler(State(state): State<AppState>) -> axum::response::Response {
761 match state.review_metrics_cache().get().await {
762 Some(report) => axum::Json(report).into_response(),
763 None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
764 }
765}
766
767/// `GET /api/console/metrics/mpm` — return the latest trusty-mpm metrics report.
768///
769/// Why: surfaces trusty-mpm session-fleet + supervisor health to the SPA without
770/// per-request MCP calls (the background poller keeps the cache warm). This is
771/// the coarse, low-frequency health cache; the Sessions tab polls the live
772/// `/api/console/sessions` list at a faster cadence for active monitoring.
773/// What: returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when no
774/// poll has completed yet (binary absent or first boot).
775/// Test: `test_metrics_mpm_route_cold_cache_returns_503` below.
776async fn metrics_mpm_handler(State(state): State<AppState>) -> axum::response::Response {
777 match state.mpm_metrics_cache().get().await {
778 Some(report) => axum::Json(report).into_response(),
779 None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
780 }
781}
782
783/// Query params for the analyze visualize route.
784///
785/// Why: The index id must be a query param so the Svelte component can change
786/// the selected index without a page navigation.
787/// What: `index` is the analyze index id (string). Optional: no default —
788/// returns 400 when absent.
789/// Test: `test_analyze_visualize_handler_no_index_returns_400` below.
790#[derive(Deserialize)]
791struct VisualizeQuery {
792 index: Option<String>,
793}
794
795/// `GET /api/console/metrics/analyze/indexes` — list analyze indexes via stdio.
796///
797/// Why: The Analyze tab needs a list of indexes to populate the dropdown.
798/// This route calls the analyze stdio MCP (via `McpServiceHandle::call_tool_checked`)
799/// instead of the browser hitting the analyze daemon HTTP directly, honouring
800/// the #1104 architecture principle: the console is a stdio MCP client only.
801/// Using `call_tool_checked` instead of `call_tool_raw` prevents a raw -32601
802/// JSON-RPC error from reaching the browser as a 502 when the stale daemon lacks
803/// the `list_analyze_indexes` tool — the capability-gate returns `ToolUnavailable`
804/// which maps to a clean 503 with an actionable hint.
805/// What: Calls the `list_analyze_indexes` MCP tool (which proxies `GET /indexes`
806/// on the daemon). Returns the JSON array on 200, 503+hint when the analyze binary
807/// is absent, in backoff, degraded, or the tool is not in the cached tool set;
808/// 502 on any other error.
809/// Test: `test_analyze_indexes_absent_binary_returns_503` and
810/// `test_analyze_indexes_tool_unavailable_returns_degraded_hint` below.
811async fn analyze_indexes_handler(State(state): State<AppState>) -> axum::response::Response {
812 match state
813 .analyze_handle()
814 .call_tool_checked("list_analyze_indexes", serde_json::json!({}))
815 .await
816 {
817 Ok(val) => axum::Json(val).into_response(),
818 Err(McpHandleError::ToolUnavailable { tool, hint }) => {
819 tracing::warn!(
820 tool = %tool,
821 hint = %hint,
822 "analyze_indexes_handler: tool not available — capability-gate triggered"
823 );
824 (
825 StatusCode::SERVICE_UNAVAILABLE,
826 axum::Json(serde_json::json!({
827 "status": "degraded",
828 "hint": hint,
829 })),
830 )
831 .into_response()
832 }
833 Err(
834 McpHandleError::Absent
835 | McpHandleError::Backoff { .. }
836 | McpHandleError::Degraded { .. },
837 ) => StatusCode::SERVICE_UNAVAILABLE.into_response(),
838 Err(e) => {
839 tracing::warn!("analyze_indexes_handler error: {e:#}");
840 StatusCode::BAD_GATEWAY.into_response()
841 }
842 }
843}
844
845/// `GET /api/console/metrics/analyze/visualize?index=<id>` — combined viz data.
846///
847/// Why: The Analyze tab needs graph nodes, entities, and clusters in one round
848/// trip. This route calls the analyze stdio MCP for all three without the
849/// browser hitting the analyze daemon HTTP directly (#1104 architecture).
850/// Using `call_tool_checked` prevents a raw -32601 from reaching the browser
851/// as a 502 when a stale daemon lacks `extract_graph`/`list_entities`/
852/// `cluster_concepts` — the capability-gate returns `ToolUnavailable` which maps
853/// to a clean 503+hint response.
854/// What: Calls `extract_graph`, `list_entities`, and `cluster_concepts` (k=8)
855/// via `McpServiceHandle::call_tool_checked` and returns a combined JSON object:
856/// `{"graph": ..., "entities": ..., "clusters": ...}`. Missing index param
857/// returns 400 (BAD_REQUEST). Absent binary, backoff, degraded, or tool
858/// unavailable returns 503 (SERVICE_UNAVAILABLE) with optional hint JSON.
859/// A hard graph error (non-absent/backoff/tool-unavailable) returns 502 (BAD_GATEWAY).
860/// Test: `test_analyze_visualize_handler_no_index_returns_400` and
861/// `test_analyze_visualize_handler_absent_binary_returns_503` below.
862async fn analyze_visualize_handler(
863 State(state): State<AppState>,
864 Query(params): Query<VisualizeQuery>,
865) -> axum::response::Response {
866 let index_id = match params.index {
867 Some(id) if !id.is_empty() => id,
868 _ => {
869 return (
870 StatusCode::BAD_REQUEST,
871 axum::Json(json!({"error": "missing required query param: index"})),
872 )
873 .into_response();
874 }
875 };
876
877 let handle = state.analyze_handle();
878 let args = serde_json::json!({ "index_id": index_id });
879
880 // NOTE: although `tokio::join!` normally drives all three futures
881 // concurrently, these three `call_tool_checked` calls share a single stdio
882 // child process behind `McpServiceHandle`'s inner `Arc<Mutex<StdioMcpClient>>`.
883 // Each call acquires that inner mutex for the full duration of its
884 // JSON-RPC round trip, so the three futures effectively serialize behind
885 // the lock — `join!` does not provide real I/O parallelism here. The
886 // `join!` form is retained for code readability (all three results
887 // collected symmetrically) and because the serialization is transparent
888 // to callers. If the analyze MCP child ever supports multiplexed requests
889 // (separate stdin/stdout framing per call), this join would gain true
890 // concurrency automatically without changing the call sites.
891 let (graph_res, entities_res, clusters_res) = tokio::join!(
892 handle.call_tool_checked("extract_graph", args.clone()),
893 handle.call_tool_checked("list_entities", args.clone()),
894 handle.call_tool_checked("cluster_concepts", {
895 let mut a = args.clone();
896 if let Some(m) = a.as_object_mut() {
897 m.insert("k".to_string(), serde_json::json!(8));
898 }
899 a
900 }),
901 );
902
903 // Classify the graph result: tool unavailable → 503+hint, absent/backoff/degraded → 503,
904 // hard error → 502, success → combine with best-effort entities and clusters.
905 match &graph_res {
906 Err(McpHandleError::ToolUnavailable { tool, hint }) => {
907 tracing::warn!(
908 tool = %tool,
909 hint = %hint,
910 "analyze_visualize_handler: tool not available — capability-gate triggered"
911 );
912 return (
913 StatusCode::SERVICE_UNAVAILABLE,
914 axum::Json(serde_json::json!({
915 "status": "degraded",
916 "hint": hint,
917 })),
918 )
919 .into_response();
920 }
921 Err(
922 McpHandleError::Absent
923 | McpHandleError::Backoff { .. }
924 | McpHandleError::Degraded { .. },
925 ) => {
926 return StatusCode::SERVICE_UNAVAILABLE.into_response();
927 }
928 Err(e) => {
929 tracing::warn!("analyze_visualize_handler graph error: {e:#}");
930 return StatusCode::BAD_GATEWAY.into_response();
931 }
932 Ok(_) => {}
933 }
934
935 // Log a warning when a best-effort tool is missing (e.g. stale daemon that
936 // predates list_entities or cluster_concepts). We do NOT return 503 here —
937 // these two are genuinely best-effort and the route still returns a useful
938 // partial payload. The primary `extract_graph` gate above is the hard 503
939 // path; these are only observable degradation signals.
940 if let Err(McpHandleError::ToolUnavailable { tool, .. }) = &entities_res {
941 tracing::warn!(
942 tool = %tool,
943 "analyze_visualize_handler: list_entities tool unavailable — returning partial payload"
944 );
945 }
946 if let Err(McpHandleError::ToolUnavailable { tool, .. }) = &clusters_res {
947 tracing::warn!(
948 tool = %tool,
949 "analyze_visualize_handler: cluster_concepts tool unavailable — returning partial payload"
950 );
951 }
952
953 let combined = json!({
954 "graph": graph_res.unwrap_or(serde_json::Value::Null),
955 "entities": entities_res.unwrap_or(serde_json::Value::Null),
956 "clusters": clusters_res.unwrap_or(serde_json::Value::Null),
957 });
958 axum::Json(combined).into_response()
959}
960
961// ─── tests ───────────────────────────────────────────────────────────────────
962
963// ─── tests ───────────────────────────────────────────────────────────────────
964
965#[cfg(test)]
966mod tests;