1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
//! Shared HTTP server scaffolding for trusty-* daemons.
//!
//! Why: Every trusty-* daemon wants the same axum middleware stack (permissive
//! CORS for local browser UIs, a tracing layer, gzip compression) and the same
//! fast-fail reqwest client when one daemon calls another. Centralising removes
//! drift between trusty-search, future trusty-memory daemons, etc.
//!
//! What: pure helpers — no global state.
//! - [`with_standard_middleware`] layers CORS/Trace/Compression on a router.
//! - [`with_guarded_middleware`] additionally applies the router-wide
//! same-origin write guard ([`origin_guard`], #3304) so destructive daemon
//! write routes are not exposed to cross-origin CSRF.
//! - [`with_guarded_middleware_same_origin_cors`] is the same stack with the
//! permissive CORS policy swapped for [`same_origin_cors`], so a browser
//! page on an untrusted origin cannot READ the daemon's responses either
//! (#5052).
//! - [`daemon_http_client`] builds a reqwest client with short timeouts so
//! CLI commands never hang on a missing daemon.
//! - `bearer_auth` (behind `daemon-token`) adds the caller check the origin
//! guard deliberately does not perform: `guard_write_origin` passes every
//! request that sends no `Origin`, so a loopback daemon still served any
//! local process until #5439.
//!
//! Test: `cargo test -p trusty-common --features axum-server` covers router
//! composition (smoke) and client construction (timeouts surfaced through
//! the public `reqwest::Client` API — we just assert no error on build).
use ;
use ;
use json;
use ;
/// #5439: gated on `daemon-token` as well as this module's own `axum-server`,
/// because the credential it verifies lives in `crate::daemon_token`.
pub use ;
pub use ;
/// Apply the standard trusty-* middleware stack to an axum router.
///
/// Why: Local browser-based UIs (trusty-search SPA, future dashboards) need
/// permissive CORS to talk to `127.0.0.1:<port>`; every daemon benefits from
/// request tracing for debugging; gzip is a cheap wire-size win.
/// What: layers `CorsLayer` (any origin/methods/headers), `TraceLayer` (HTTP
/// span), and `CompressionLayer` (gzip) in that order. The order matters:
/// CORS must run on every response (including 404s from inner routes), and
/// compression should be outermost so the trace span captures the encoded
/// size if needed.
///
/// Compression skips `text/event-stream` (SSE) responses: gzip's trailer is
/// only flushed at stream close, so a fast-completing SSE response leaves the
/// client (reqwest) mid-decode and surfaces as
/// `Transport error: error decoding response body`. tower-http 0.5 ships
/// `NotForContentType::SSE` ("text/event-stream") for exactly this case; we
/// compose it with `DefaultPredicate` so all other heuristics (min size, no
/// already-compressed media) still apply.
/// Test: smoke-tested via the dependent crates' integration tests — any
/// regression breaks `cargo test -p trusty-search-service`.
/// The trace + gzip half of the standard stack, plus a caller-chosen CORS policy.
///
/// Why: `with_standard_middleware` and
/// [`with_guarded_middleware_same_origin_cors`] differ in exactly one layer —
/// the CORS policy. Keeping the layer ORDER (and the SSE compression carve-out
/// documented on `with_standard_middleware`) in one function means a future fix
/// to that order lands once rather than twice.
/// What: layers compression (innermost), trace, then `cors` (outermost).
/// Test: `with_standard_middleware_composes`,
/// `with_guarded_middleware_same_origin_cors_composes`.
/// A CORS policy that reflects ONLY same-machine origins. (#5052)
///
/// Why: `allow_origin(Any)` lets any page the operator happens to have open
/// READ a loopback daemon's responses. A loopback bind does not contain that —
/// the attacker's JavaScript runs inside the operator's own browser, which can
/// reach `127.0.0.1` — so for a daemon whose GET surface carries conversation
/// content (`trusty-agents`' `/api/events`, `/api/tasks`, `/api/sessions/*`),
/// permissive CORS is the difference between "unreachable off-host" and
/// "readable by any web page". Reflecting only same-machine origins keeps every
/// legitimate consumer working: the daemon's own SPA (same-origin), a `pnpm dev`
/// Vite server on `http://localhost:5173`, a Tauri webview, and the daemon's own
/// resolved non-loopback bind (#3269). Server-side callers (the console reverse
/// proxy, `curl`, MCP stdio bridges) send no `Origin` at all and are unaffected
/// — CORS is a browser-enforced policy, never a server-side access control,
/// which is why this is defence in depth BEHIND per-route auth, not a
/// replacement for it.
/// What: builds a [`CorsLayer`] whose allowed origin is a predicate —
/// [`origin_is_loopback`] OR [`origin_is_local_webview`] OR
/// [`origin_matches_self`] against `self_origins`. Methods and headers stay
/// `Any`; credentials are NOT allowed, so no ambient cookie/auth is ever
/// attached cross-origin.
/// Test: `same_origin_cors_predicate_allows_local`,
/// `same_origin_cors_predicate_rejects_remote` below; end-to-end in
/// trusty-agents' `api::server::tests::event_tickets` —
/// `cross_origin_request_gets_no_cors_reflection` and
/// `loopback_origin_is_cors_reflected`.
/// Whether [`same_origin_cors`] reflects `origin`.
///
/// Why: the predicate closure handed to `AllowOrigin::predicate` is not
/// reachable from a unit test; naming the decision separately makes the policy
/// directly testable, including the DNS-rebinding lookalikes
/// (`127.0.0.1.evil.com`) that [`origin_is_loopback`] already rejects.
/// What: `true` for a loopback host, a local webview origin, or one of the
/// daemon's own resolved non-loopback bind addresses.
/// Test: `same_origin_cors_predicate_allows_local`,
/// `same_origin_cors_predicate_rejects_remote`.
/// Apply the standard middleware stack PLUS the router-wide same-origin write
/// guard (#3304).
///
/// Why: the sibling trusty-* daemons (search, memory, analyze, mpm) inherit the
/// permissive-CORS [`with_standard_middleware`] stack, which leaves their
/// DESTRUCTIVE write routes (daemon shutdown, index/palace/drawer deletion,
/// session spawn/stop, the `/rpc` JSON-RPC surface) open to cross-origin CSRF
/// from any page the operator visits. This helper composes
/// [`guard_write_origin`] into that stack so every daemon adopts the console's
/// proven guard (#3280) router-wide with a one-line change, instead of each
/// re-implementing it (architecture review tranche 1).
/// What: layers [`guard_write_origin`] (via
/// [`axum::middleware::from_fn_with_state`] carrying `self_origins`) as the
/// INNERMOST middleware — closest to the routes, so it wraps every route
/// including those merged in later — then applies [`with_standard_middleware`]
/// (compression/trace/CORS) on top. The guard is method-gated (only
/// POST/PUT/PATCH/DELETE), so `GET` reads and SSE/WebSocket upgrades pass
/// through untouched; it fails open on a missing `Origin` header, so all
/// server-side callers (the console reverse proxy, `curl`, the MCP stdio
/// bridge) keep working. Pass `SelfOrigins::default()` for a loopback-only bind
/// or `SelfOrigins::from_bind_addrs(&addrs)` to additionally trust the daemon's
/// own non-loopback (e.g. Tailscale) bind address (#3269).
/// Test: `with_guarded_middleware_composes` below; consuming crates' per-daemon
/// guard regression tests.
/// [`with_guarded_middleware`], but with [`same_origin_cors`] in place of the
/// permissive CORS policy. (#5052)
///
/// Why: the write guard stops a cross-origin page from DRIVING a daemon; it does
/// nothing about a cross-origin page READING one, because reads are `GET` and
/// the guard is method-gated. For a daemon whose GET surface is telemetry that
/// is fine. For one whose GET surface is conversation content it is not — see
/// [`same_origin_cors`]. This entry point exists so such a daemon opts into the
/// tighter policy with a one-line change instead of assembling the stack itself
/// (and drifting from the layer order in [`with_middleware_stack`]).
/// What: layers [`guard_write_origin`] innermost, then compression/trace, then
/// the same-origin CORS policy built from the SAME `self_origins` allowlist the
/// guard uses.
/// Test: `with_guarded_middleware_same_origin_cors_composes` below; the
/// end-to-end behaviour is covered by trusty-agents'
/// `api::server::tests::event_tickets`.
/// Build a `reqwest::Client` configured for daemon-to-daemon calls.
///
/// Why: every CLI command that talks to the daemon must fail fast when the
/// daemon is not running. Without timeouts, reqwest waits for the OS TCP
/// stack (minutes on some platforms), freezing the terminal.
/// What: delegates to [`crate::http_client::loopback_client`] — proxies off
/// (#4392), 2 s connect timeout, 5 s total request timeout. Returns
/// `anyhow::Result` so callers can `?`-propagate alongside other anyhow
/// errors without conversion boilerplate.
/// Test: `daemon_http_client_builds` — construction succeeds with the
/// configured timeouts; the timeout values themselves are exercised in the
/// dependent CLIs (manual: stop daemon, run `trusty-search status`). The proxy
/// immunity is proven in `http_client::tests`.
/// Standard health-check handler returning `{"status":"ok","version":"<v>"}`.
///
/// Why: trusty-search and trusty-memory both expose `/health`, but their
/// payload shapes drifted (one returned plain `"ok"`, the other JSON with
/// version). Centralising gives every trusty-* daemon the same JSON contract
/// so monitoring tooling (curl probes, MCP supervisors) can rely on a single
/// shape.
/// What: returns a 200 OK with body `{"status":"ok","version":"<version>"}`.
/// The `version` argument is `&'static str` so callers can pass
/// `env!("CARGO_PKG_VERSION")` without allocation.
/// Usage: `.route("/health", get(|| health_handler(env!("CARGO_PKG_VERSION"))))`
/// Test: `health_handler_returns_expected_json` exercises the handler
/// directly and asserts the JSON body.
pub async