agentmux_srv/server/routes.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The HTTP route table and frontend fallback.
5//! Split out of server/mod.rs unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.1).
6
7use super::*;
8use agentmux_common::AUTH_KEY_HEADER;
9
10/// The two routers srv serves. `full` is every route and is bound on
11/// loopback only. `lan` is what `backend::lan_listeners` binds on LAN
12/// interfaces when LAN discovery is on: the routes a LAN peer calls (all
13/// behind `lan_or_full_auth_middleware`), plus health and the WhatsApp
14/// webhook, which authenticate themselves. Nothing that requires the full
15/// `auth_key` is served off-host, so a LAN listener has no full-key surface.
16pub struct SrvRouters {
17 pub full: Router,
18 pub lan: Router,
19}
20
21/// Build the full router (see [`build_routers`]).
22#[cfg(test)]
23pub fn build_router(state: AppState) -> Router {
24 build_routers(state).full
25}
26
27/// Build both routers with all routes, auth middleware, and CORS.
28pub fn build_routers(state: AppState) -> SrvRouters {
29 build_routers_with(state, crate::headless::frontend_dir())
30}
31
32/// [`build_routers`], with the frontend directory passed in (headless
33/// `--frontend-dir`, or `None`).
34pub(crate) fn build_routers_with(state: AppState, frontend_dir: Option<&std::path::Path>) -> SrvRouters {
35 // CORS: reflect only loopback origins, plus any headless `--allowed-origin`.
36 //
37 // Before the 2026-05-11 security audit (C3) this allowed any origin
38 // (matching the historical Go pkg/web/web.go). That made every web
39 // page the user happened to have open a potential CSRF source —
40 // localhost is not a trust boundary on a developer machine.
41 //
42 // The legitimate cross-origin callers are:
43 // - The CEF frontend served from `http://127.0.0.1:<host-port>`
44 // - Vite dev server at `http://localhost:5173` (and similar)
45 //
46 // Both are loopback. The predicate accepts http://127.0.0.1:* and
47 // http://localhost:* (any port, http only; https is irrelevant for
48 // loopback). External origins are denied, which means a malicious
49 // page in the user's browser can't drive the sidecar even if it
50 // discovers the port.
51 use tower_http::cors::AllowOrigin;
52 let cors = CorsLayer::new()
53 .allow_origin(AllowOrigin::predicate(|origin, _req| {
54 origin.to_str().is_ok_and(is_allowed_origin)
55 }))
56 .allow_methods(Any)
57 .allow_headers(vec![
58 header::CONTENT_TYPE,
59 header::AUTHORIZATION,
60 header::ACCEPT,
61 "X-Session-Id".parse().unwrap(),
62 AUTH_KEY_HEADER.parse().unwrap(),
63 "X-Requested-With".parse().unwrap(),
64 "x-vercel-ai-ui-message-stream".parse().unwrap(),
65 // A video poster reads a file's first bytes (stream-local-file,
66 // SPEC_AGENT_PANE_RICH_OUTPUT_2026_09_27.md §5). The request also
67 // carries X-AuthKey, so it's preflighted and Range must be listed.
68 header::RANGE,
69 ])
70 // Custom response headers are invisible to cross-origin `fetch()`
71 // callers (Response.headers.get(...) silently returns null) unless
72 // explicitly exposed here — a browser CORS default, not an
73 // allow_headers concern (that list governs REQUEST headers only).
74 // `X-ZoneFileInfo` (files.rs's handle_mux_file) is read by
75 // `fetchMuxFile` on every blockfile GET; without this, any 200
76 // response is indistinguishable from a malformed one to the
77 // frontend ("missing zone file info for ..." — the exact failure
78 // mode `TermWrap.loadInitialTerminalData()` hit once terminal
79 // scrollback write-through started actually producing real 200s
80 // instead of always-404, see
81 // SPEC_TERMINAL_SCROLLBACK_PERSISTENCE_2026_07_23.md §2.1's
82 // follow-up). Not dev-only — the CEF frontend's own origin is
83 // cross-origin from this server in production too (see the
84 // allow_origin comment above).
85 // `Content-Range` carries a ranged stream-local-file response's
86 // total size, which inline video/audio read before play (Codex P2 on
87 // #4073).
88 .expose_headers(vec!["X-ZoneFileInfo".parse().unwrap(), header::CONTENT_RANGE]);
89
90 // The routes an LAN peer actually calls when forwarding a jekt or
91 // looking up which agents this instance hosts
92 // (`LanDiscoveryController::find_agent`, `server/reactive.rs`'s Tier-3
93 // forward). Kept OUT of `reactive_routes`/`authed_routes` deliberately
94 // — merged at the top level with their own `lan_or_full_auth_middleware`
95 // instead, so a LAN peer's scoped `lan_key` (see `Config::lan_key`) is
96 // accepted here without also being accepted by `authed_routes`'s much
97 // broader surface (the general `auth_middleware` there requires the
98 // full `auth_key` only). See `lan_or_full_auth_middleware`'s doc
99 // comment for why this can't just be nested inside `authed_routes`.
100 let lan_forward_routes = Router::new()
101 .route("/agentmux/reactive/inject", post(reactive::handle_reactive_inject))
102 .route("/agentmux/reactive/agent", get(reactive::handle_reactive_agent))
103 // Names, kinds and status only — see `handle_reactive_agent_names`' doc comment for why
104 // this is deliberately not `/agentmux/reactive/agents` (which stays
105 // behind full auth because it serializes internal routing fields).
106 .route(
107 "/agentmux/reactive/agent-names",
108 get(reactive::handle_reactive_agent_names),
109 )
110 // One live instance per agent, LAN tier (SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24 §4.4).
111 .route("/agentmux/agent/holding", get(agent_takeover::handle_agent_holding))
112 // The same names, as a versioned snapshot and an SSE stream, so a phone
113 // follows changes without polling (SPEC_LAN_FLEET_FEED_2026_10_03.md).
114 .route("/agentmux/fleet", get(handle_fleet))
115 .route("/agentmux/fleet/events", get(handle_fleet_events))
116 // Identity M4a: inner to the auth layer (route_layer order: the
117 // last one added runs first), so it sees which key authenticated.
118 .route_layer(middleware::from_fn_with_state(
119 state.clone(),
120 caller::caller_middleware,
121 ))
122 .route_layer(middleware::from_fn_with_state(
123 state.clone(),
124 lan_or_full_auth_middleware,
125 ));
126
127 // Reactive routes. Previously registered without auth on the
128 // assumption that localhost is a trust boundary; the 2026-05-11
129 // security audit (C1 + C2) showed that any local process — or a
130 // web page driving 127.0.0.1 via the permissive CORS layer — could
131 // drive `/agentmux/reactive/inject` and reconfigure the cloud
132 // muxbus poller. These routes are now merged into `authed_routes`
133 // below and gated by `auth_middleware`. (`/inject` and `/agent` are
134 // NOT here — see `lan_forward_routes` above.)
135 let reactive_routes = Router::new()
136 .route("/agentmux/reactive/agents", get(reactive::handle_reactive_agents))
137 .route("/agentmux/reactive/audit", get(reactive::handle_reactive_audit))
138 .route("/agentmux/reactive/register", post(reactive::handle_reactive_register))
139 // Holder side of a takeover — full auth only, never LAN (spec §4.6).
140 .route("/agentmux/agent/release", post(agent_takeover::handle_agent_release))
141 .route(
142 "/agentmux/reactive/unregister",
143 post(reactive::handle_reactive_unregister),
144 )
145 // Identity M3 (spec §5): the one place a typed agent name is
146 // interpreted. Called by agentmux-mcp before WorkEnqueue/CronCreate.
147 .route(
148 "/agentmux/agents/resolve",
149 post(name_resolution::handle_resolve_agent_name),
150 )
151 .route(
152 "/agentmux/reactive/ensure-signing-key",
153 post(reactive::handle_reactive_ensure_signing_key),
154 )
155 .route(
156 "/agentmux/reactive/poller/stats",
157 get(reactive::handle_reactive_poller_stats),
158 )
159 .route(
160 "/agentmux/reactive/poller/config",
161 post(reactive::handle_reactive_poller_config),
162 )
163 .route(
164 "/agentmux/reactive/poller/status",
165 get(reactive::handle_reactive_poller_status),
166 )
167 .route(
168 "/agentmux/reactive/transcript",
169 get(reactive::handle_reactive_transcript),
170 )
171 .route(
172 "/agentmux/reactive/supervisor-decision",
173 post(reactive::handle_reactive_supervisor_decision),
174 )
175 // SPEC_AGENT_HISTORY_SEARCH_2026_09_17.md — search an agent's own past
176 // conversations on disk. Distinct from `/transcript`, which is the LIVE
177 // session's tail only. Full-auth route (not in the LAN-scoped set):
178 // this reads conversation content, which is exactly what the LAN key
179 // must not reach.
180 .route(
181 "/agentmux/reactive/history/search",
182 get(reactive::handle_reactive_history_search),
183 );
184
185 // MessageBus routes (authed, localhost-only)
186 let bus_routes = Router::new()
187 .route("/api/bus/register", post(messagebus::handle_register))
188 .route("/api/bus/send", post(messagebus::handle_send))
189 .route("/api/bus/inject", post(messagebus::handle_inject))
190 .route("/api/bus/broadcast", post(messagebus::handle_broadcast))
191 .route("/api/bus/messages", get(messagebus::handle_read_messages))
192 .route("/api/bus/messages/delete", post(messagebus::handle_delete_messages))
193 .route("/api/bus/agents", get(messagebus::handle_list_agents));
194
195 let authed_routes = Router::new()
196 .route(
197 "/ws",
198 get(websocket::handle_ws).route_layer(middleware::from_fn(ws_origin_guard)),
199 )
200 .route("/agentmux/service", post(service::handle_service))
201 .route("/agentmux/file", get(files::handle_mux_file))
202 .route("/agentmux/stream-file", get(stub_501))
203 .route("/agentmux/stream-file/*path", get(stub_501))
204 .route("/agentmux/stream-local-file", get(files::handle_stream_local_file))
205 .route("/api/post-chat-message", get(stub_501).post(stub_501))
206 .route("/docsite/*path", get(files::handle_docsite))
207 .route("/schema/*path", get(files::handle_schema))
208 .route("/api/lan-instances", get(handle_lan_instances))
209 .route("/agentmux/discovery", get(handle_discovery))
210 .route("/agentmux/diag/sagas", get(handle_diag_sagas))
211 // Streaming-bash wrapper publish endpoint
212 // (SPEC_STREAMING_BASH_RUNNER_2026_05_11.md §4.3). agentmux-bashwrap
213 // POSTs `{event, scopes, data}` here while a PreToolUse-rewritten
214 // Bash command is running; we forward to the in-process MPS broker.
215 // Auth-gated like the other reactive routes (PR #801 pattern).
216 .route("/agentmux/wps/publish", post(handle_wps_publish))
217 // Persistent shell launch endpoint
218 // (SPEC_PERSISTENT_SHELL_NODE_2026_06_11.md §5.3). agentmux-mcp's
219 // Shell tool POSTs here; we publish shell_node_create + spawn a
220 // ShellNodeRunner that streams shell_chunk events to the frontend.
221 .route("/api/v1/shell/create", post(handle_shell_create))
222 // Stop a persistent shell (Phase 3). agentmux-mcp's `ShellStop` tool
223 // POSTs here; tree-kills the shell's process group.
224 .route("/api/v1/shell/stop", post(handle_shell_stop))
225 // Phase 3b — write to a running shell's stdin (for interactive prompts).
226 .route("/api/v1/shell/input", post(handle_shell_input))
227 // Phase 3b — query running state, exit code, and line count.
228 .route("/api/v1/shell/status", post(handle_shell_status))
229 // Real PTY-backed shell, for genuinely interactive programs the
230 // piped /api/v1/shell/* family above can't drive (see
231 // docs/specs/SPEC_AGENT_INTERACTIVE_PTY_SHELL_API_2026_09_10.md).
232 // agentmux-mcp's `PtyShell*` tools POST here.
233 .route("/api/v1/ptyshell/create", post(handle_pty_shell_create))
234 .route("/api/v1/ptyshell/input", post(handle_pty_shell_input))
235 .route("/api/v1/ptyshell/resize", post(handle_pty_shell_resize))
236 .route("/api/v1/ptyshell/read", post(handle_pty_shell_read))
237 .route("/api/v1/ptyshell/status", post(handle_pty_shell_status))
238 .route("/api/v1/ptyshell/stop", post(handle_pty_shell_stop))
239 // Open a pane (editor/term/browser/…) from an agent tool call.
240 // agentmux-mcp's OpenEditor tool POSTs `{view:"editor", file, …}` here;
241 // shares the exact pane.open logic with the WebSocket RPC handler
242 // (app_api::open_pane). See ANALYSIS_AGENT_APP_API_OPEN_IN_EDITOR_2026_05_30.
243 .route("/api/v1/pane/open", post(handle_pane_open))
244 // Widgets for agents (WidgetList, WidgetInstall): an install waits
245 // for the user's answer, which only the host can carry.
246 .route("/api/v1/widgets", get(super::widget_agent_handlers::handle_widgets_list))
247 .route("/api/v1/widgets/install", post(super::widget_agent_handlers::handle_widgets_install))
248 // Open (launch) an agent into a pane from an agent tool call —
249 // agentmux-mcp's OpenAgent tool POSTs `{agent_id, tab_id?, …}` here.
250 // Shares the exact agent.open logic (incl. its AGENT_OPEN_LOCKS
251 // TOCTOU serialization) with the WebSocket RPC handler via
252 // app_api::open_agent_impl. Until this route existed, automation
253 // could STOP an agent (/api/v1/fleet/bulk-stop, cross-channel) but
254 // could not START one anywhere — see
255 // REPORT_AGENT_OPEN_API_GAP_2026_09_06.md.
256 .route("/api/v1/agent/open", post(handle_agent_open))
257 // One live instance per agent, Phase 2 (SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24 §4.6).
258 .route("/api/v1/agent/takeover", post(agent_takeover::handle_agent_takeover))
259 // Voice speech-to-text: the renderer POSTs mic audio (one
260 // silence-bounded utterance per request); we forward to a Whisper
261 // backend and return the transcript. Key stays server-side.
262 // See SPEC_VOICE_STT_ENGINE_2026_06_20.md and #1591.
263 .route("/api/v1/voice/transcribe", post(voice::handle_voice_transcribe))
264 // Image attachments in the agent composer: serve stored files and
265 // accept the paste fallback's streamed upload.
266 // SPEC_AGENT_PANE_IMAGE_ATTACHMENTS_2026_09_26.md §6.2, §6.5.
267 .route("/api/v1/attachments/upload", post(attachments::handle_attachment_upload))
268 .route("/api/v1/attachments/:id/:kind", get(attachments::handle_attachment_file))
269 // First-class agent API (SPEC_AGENT_API_FIRST_CLASS_SURFACE_2026_06_17.md).
270 // `GET /api/v1/self?block_id=` resolves the caller's place in the tree;
271 // `POST /api/v1/window/name` sets the window display name (taskbar title).
272 // agentmux-mcp's `WhoAmI` / `SetWindowName` tools call these.
273 .route("/api/v1/self", get(handle_self))
274 // The connections an agent's Shell/PtyShell may name (`ConnList` tool,
275 // SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md §8.1).
276 .route("/api/v1/conn/list", get(app_api::connections::handle_conn_list))
277 // ssh's askpass, through agentmux-bashwrap: answered only for a live
278 // per-ssh secret (backend::remote::askpass).
279 .route("/api/v1/askpass", post(app_api::connections::handle_askpass))
280 .route("/api/v1/window/name", post(handle_window_name))
281 // Naming verbs (SPEC §4.3): rename the caller's own tab / pane / workspace
282 // (or an explicit target). agentmux-mcp's SetTabName / SetPaneTitle /
283 // SetWorkspaceName tools POST here.
284 .route("/api/v1/tab/name", post(handle_tab_name))
285 .route("/api/v1/pane/title", post(handle_pane_title))
286 .route("/api/v1/workspace/name", post(handle_workspace_name))
287 // Introspection verbs (SPEC §4.6): read-only views of the UI tree so an
288 // agent can see what's around it. agentmux-mcp's GetLayout / ListWindows
289 // / ListWorkspaces / ListTabs tools GET these.
290 .route("/api/v1/layout", get(handle_layout))
291 .route("/api/v1/windows", get(handle_list_windows))
292 .route("/api/v1/workspaces", get(handle_list_workspaces))
293 .route("/api/v1/tabs", get(handle_list_tabs))
294 // Layout / navigation verbs (SPEC §4.5): switch the active tab, open a
295 // new tab, focus a window. agentmux-mcp's SetActiveTab / NewTab /
296 // FocusWindow tools POST here.
297 .route("/api/v1/tab/activate", post(handle_tab_activate))
298 .route("/api/v1/tab/new", post(handle_tab_new))
299 .route("/api/v1/window/focus", post(handle_window_focus))
300 // Live-state introspection for the `muxspect` CLI (Phase 1 of
301 // docs/specs/SPEC_MUXSPECT_LIVE_INTROSPECTION_TOOL_2026_08_01.md) —
302 // read-only, diagnostic-only, reached the same way agentmux-mcp
303 // reaches every other /api/v1/* route (X-AuthKey from the caller's
304 // own environment, no new IPC).
305 .route("/api/v1/muxspect/list", get(muxspect_handlers::handle_muxspect_list))
306 .route("/api/v1/muxspect/layout", get(muxspect_handlers::handle_muxspect_layout))
307 .route("/api/v1/muxspect/describe", get(muxspect_handlers::handle_muxspect_describe))
308 // Cross-instance lookup — Ext 4 of
309 // docs/reports/REPORT_MUXSPECT_MUXLOG_CROSS_CHANNEL_INSPECTION_2026_08_22.md.
310 // Checks this instance first, then every other channel via the shared
311 // reactive registry (same mechanism `conversations` below already
312 // uses) — see the handler's own doc comment.
313 .route("/api/v1/muxspect/find", get(muxspect_handlers::handle_muxspect_find))
314 // Dock diagnosis/remediation extension (2026-08-06 spec) — `dock` is
315 // read-only like the two routes above; `dock/clear` is this module's
316 // first mutating route (narrowly scoped — see the handler's own doc
317 // comment for why).
318 .route("/api/v1/muxspect/dock", get(muxspect_handlers::handle_muxspect_dock))
319 .route("/api/v1/muxspect/dock/clear", post(muxspect_handlers::handle_muxspect_dock_clear))
320 // Durable declared-background task registry — see the handler's own
321 // doc comment for why this is a separate, SQLite-backed source of
322 // truth from `dock` above rather than another view over the same
323 // ephemeral cache.
324 .route(
325 "/api/v1/muxspect/background-tasks",
326 get(muxspect_handlers::handle_muxspect_background_tasks),
327 )
328 // Migration doctor — `migrate --verify`'s report from inside a live
329 // instance (SPEC_MIGRATION_SYSTEM_HARDENING_2026_08_03.md Phase 1c).
330 // Read-only; see the handler's own doc comment.
331 .route(
332 "/api/v1/muxspect/migrations",
333 get(muxspect_handlers::handle_muxspect_migrations),
334 )
335 // Sender-liveness verdict for a JEKT's claimed FROM — see the
336 // handler's own doc comment (SPEC_MUXSPECT_VERIFY_SENDER_2026_08_21.md).
337 .route(
338 "/api/v1/muxspect/verify-sender",
339 get(muxspect_handlers::handle_muxspect_verify_sender),
340 )
341 // All-tier conversation glance (host/cross-channel previews, LAN/WAN
342 // liveness) — see the handler's own doc comment
343 // (SPEC_MUXSPECT_CROSS_TIER_CONVERSATION_VISIBILITY_2026_08_21.md Phase A).
344 .route(
345 "/api/v1/muxspect/conversations",
346 get(muxspect_handlers::handle_muxspect_conversations),
347 )
348 // Agents' work facts, and WhoIsWorkingOn over them
349 // (SPEC_AGENT_OVERLAP_AWARENESS_2026_10_10.md §3.1, §3.2). Read-only.
350 .route("/api/v1/work-facts", get(work_facts_handlers::handle_work_facts))
351 .route("/api/v1/work-facts/who", get(work_facts_handlers::handle_who_is_working_on))
352 // ClaimWork / ReleaseWork (same spec, §3.4): informational claims,
353 // never a lock.
354 .route("/api/v1/work-claims", post(work_claims_handlers::handle_claim_work))
355 .route("/api/v1/work-claims/release", post(work_claims_handlers::handle_release_work))
356 // MuxAPI — identity / preset / memory namespaces, the MCP-facing
357 // slice of the app-API RPC surface (SPEC_AGENT_APP_API_MCP_BINDINGS_2026_06_28).
358 // The agent identity (`agent_id`) is supplied by agentmux-mcp from its
359 // AGENTMUX_AGENT_ID env. Host agents do hold the instance key, so the
360 // owner comes from the caller's token where there is one
361 // (`SelfOwner::of`); a container token is always pinned to its own
362 // agent (backend::container_credential). Each handler calls the
363 // same `app_api::*_impl` the WebSocket RPC handlers use.
364 .route("/api/v1/agent/memory/list", get(handle_agent_memory_list))
365 .route("/api/v1/agent/memory/read", get(handle_agent_memory_read))
366 .route("/api/v1/agent/memory/write", post(handle_agent_memory_write))
367 .route("/api/v1/agent/memory/history", get(handle_agent_memory_history))
368 .route("/api/v1/agent/memory/diff", get(handle_agent_memory_diff))
369 .route("/api/v1/agent/memory/revert", post(handle_agent_memory_revert))
370 // Global Memory (agent-facing) — SPEC_AGENT_FACING_GLOBAL_MEMORY_
371 // API_2026_09_15.md. Unlike the agent/memory/* routes above (each
372 // agent's own private native memory), these touch a single,
373 // shared, workspace-wide list every agent inherits at launch. Same
374 // trust model as every other route in this block: `agent_id` comes
375 // from agentmux-mcp's trusted env, never forgeable from an agent's
376 // own PTY. Never exposes or accepts a system-tier (`is_system`)
377 // entry — see `global_memory_write_impl`'s own doc comment.
378 .route("/api/v1/agent/globalmemory/write", post(handle_agent_globalmemory_write))
379 .route("/api/v1/agent/globalmemory/list", get(handle_agent_globalmemory_list))
380 .route("/api/v1/agent/globalmemory/read", get(handle_agent_globalmemory_read))
381 .route("/api/v1/agent/globalmemory/remove", post(handle_agent_globalmemory_remove))
382 // Global Memory audit trail (read side) — SPEC_AGENT_FACING_GLOBAL_
383 // MEMORY_API_2026_09_15.md Phase 3 follow-up: `bundle_version_list`/
384 // `bundle_version_get` (`bundle_versions.rs`) already existed and were
385 // tested, but nothing exposed them via MCP/REST until now. Same trust
386 // model and same system-tier exclusion as the four routes above.
387 .route("/api/v1/agent/globalmemory/history", get(handle_agent_globalmemory_history))
388 .route("/api/v1/agent/globalmemory/diff", get(handle_agent_globalmemory_diff))
389 .route("/api/v1/agent/globalmemory/revert", post(handle_agent_globalmemory_revert))
390 // An agent's memory delivered through Claude Code's `SessionStart`
391 // hook, one part per hook command, then an acknowledgement per part —
392 // SPEC_GLOBAL_MEMORY_DELIVERY_2026_09_27.md §7 P2. Called by
393 // `agentmux-bashwrap sessionstart`, whose `X-Agent-Token` names the
394 // agent whose Personal Memory is delivered.
395 .route(
396 "/api/v1/agent/memory/session-start/part",
397 post(memory_delivery_handlers::handle_session_start_part),
398 )
399 .route(
400 "/api/v1/agent/memory/session-start/ack",
401 post(memory_delivery_handlers::handle_session_start_ack),
402 )
403 .route("/api/v1/agent/preset/list", get(handle_agent_preset_list))
404 .route("/api/v1/agent/preset/get", get(handle_agent_preset_get))
405 .route("/api/v1/agent/identity/accounts", get(handle_agent_identity_accounts))
406 .route("/api/v1/agent/identity/validate", post(handle_agent_identity_validate))
407 // Agent UI automation (SPEC_AGENT_UI_AUTOMATION_CLICK_SCREENSHOT_2026_08_18.md)
408 // — proxies to the paired CEF host's browser_api CDP routes. `block_id`
409 // is stamped by agentmux-mcp from AGENTMUX_BLOCKID, same trust model as
410 // the agent/memory/preset/identity routes just above.
411 .route("/api/v1/ui/screenshot", post(ui_handlers::handle_ui_screenshot))
412 .route("/api/v1/ui/click", post(ui_handlers::handle_ui_click))
413 .route("/api/v1/ui/query", post(ui_handlers::handle_ui_query))
414 // Shortcuts (PLAN_SHORTCUTS_VERIFIED_AND_APP_API_2026_10_10.md §4) —
415 // in the window that holds the caller's own pane.
416 .route("/api/v1/ui/shortcuts/list", post(ui_shortcuts::handle_ui_shortcuts_list))
417 .route("/api/v1/ui/shortcuts/run", post(ui_shortcuts::handle_ui_run_command))
418 .route("/api/v1/ui/shortcuts/press", post(ui_shortcuts::handle_ui_press_keys))
419 // Browser-pane deep control (SPEC_AGENT_BROWSER_PANE_DEEP_CONTROL_2026_09_20.md)
420 // — own-pane-only, same identity model as the ui/* routes above.
421 // navigate/back/forward/reload/eval additionally require the
422 // caller's own pane to be a dedicated browser pane (checked
423 // host-side in browser_api::routes::reject_if_shared_target).
424 .route("/api/v1/ui/browser/open", post(ui_handlers::handle_ui_browser_open))
425 .route("/api/v1/ui/browser/snapshot", post(ui_handlers::handle_ui_browser_snapshot))
426 .route("/api/v1/ui/browser/act", post(ui_handlers::handle_ui_browser_act))
427 .route("/api/v1/ui/browser/set_files", post(ui_handlers::handle_ui_browser_set_files))
428 .route("/api/v1/ui/browser/wait_for", post(ui_handlers::handle_ui_browser_wait_for))
429 .route("/api/v1/ui/browser/handoff", post(ui_handlers::handle_ui_browser_handoff))
430 .route("/api/v1/host/browser_attention", post(ui_handlers::handle_host_browser_attention))
431 .route("/api/v1/host/widget_approval", post(super::widget_handlers::handle_host_widget_approval))
432 .route("/api/v1/host/browser_navigation", post(crate::server::browser_allowlist::handle_host_browser_navigation))
433 .route("/api/v1/host/browser_popup", post(ui_handlers::handle_host_browser_popup))
434 .route("/api/v1/host/browser_popup_window", post(ui_handlers::handle_host_browser_popup_window))
435 .route("/api/v1/ui/browser/navigate", post(ui_handlers::handle_ui_browser_navigate))
436 .route("/api/v1/ui/browser/back", post(ui_handlers::handle_ui_browser_back))
437 .route("/api/v1/ui/browser/forward", post(ui_handlers::handle_ui_browser_forward))
438 .route("/api/v1/ui/browser/reload", post(ui_handlers::handle_ui_browser_reload))
439 .route("/api/v1/ui/browser/eval", post(ui_handlers::handle_ui_browser_eval))
440 .route("/api/v1/ui/browser/dispatch_key", post(ui_handlers::handle_ui_browser_dispatch_key))
441 .route("/api/v1/ui/browser/focus_element", post(ui_handlers::handle_ui_browser_focus_element))
442 .route("/api/v1/ui/browser/focus_info", post(ui_handlers::handle_ui_browser_focus_info))
443 // Pane lifecycle (SPEC_AGENT_PANE_LIFECYCLE_CONTROL_2026_09_10.md) —
444 // `ClosePane`. Own-pane identity is verified the same way as the
445 // ui/* routes above (`verified_block_id`); the target pane, when
446 // acting on another agent's pane, is fleet-tier (see the
447 // fleet/bulk-stop comment below) — no ownership check on the
448 // target, but the caller is never anonymous in the audit log the
449 // way `FleetBulkStop`'s calls are today.
450 .route("/api/v1/agent/pane/close", post(app_api::pane::handle_close_pane))
451 .route("/api/v1/agent/self/quit", post(app_api::pane::handle_quit_self))
452 // `agentmux-mcp doctor`, from the agent's shell: its own pane by token.
453 .route("/api/v1/agent/self/mcp-health", post(app_api::mcp_health::handle_mcp_health))
454 .route("/api/v1/agent/self/mcp-heal", post(app_api::mcp_health::handle_mcp_heal))
455 // The outcome of a shutdown waiting on the user's override (§6.5):
456 // the MCP tools poll it after their 202.
457 // axum 0.7: a path parameter is `:name`; `{name}` would be a literal.
458 .route("/api/v1/agent/shutdown/:request_id", get(app_api::pane::handle_shutdown_status))
459 // Native dev-proxy registration (SPEC_NATIVE_CONTAINER_DEV_PROXY_2026_09_19.md)
460 // — `RegisterDevServer`. Same `verified_block_id` identity model as
461 // the ui/* and pane/close routes above; the backend address it
462 // actually stores is resolved server-side from the CALLER's own
463 // container, never taken from the request body.
464 .route("/api/v1/agent/dev_server/register", post(app_api::dev_server::handle_register_dev_server))
465 // Fleet control (SPEC_MULTI_AGENT_FLEET_CONTROL_2026_08_20.md) —
466 // bulk-stop is the one fleet action exposed to agentmux-mcp (see
467 // `FleetBulkStop`): stopping a controller involves no jekt signing,
468 // unlike broadcast (which an agent instead sends by looping the
469 // existing single-target signed SendMessage path client-side —
470 // see `server/app_api/fleet.rs`'s module doc comment for why no
471 // `/api/v1/fleet/broadcast` route exists). Same MCP-facing App-API
472 // trust model as the routes above: gated by the general X-AuthKey
473 // middleware wrapping this whole router, no additional per-agent
474 // scoping — fleet actions target OTHER agents' panes by design, so
475 // "own pane only" doesn't apply here the way it does to `ui/*`.
476 .route("/api/v1/fleet/bulk-stop", post(handle_fleet_bulk_stop))
477 // Cross-channel bulk-stop forward target
478 // (SPEC_FLEET_BULK_STOP_CROSS_CHANNEL_2026_08_22.md): when
479 // `fleet_bulk_stop_impl` can't find a target block_id in ITS OWN
480 // in-process controller registry, it checks the shared
481 // cross-channel registry and forwards here — same trust model as
482 // `/agentmux/reactive/inject`'s own cross-channel forward (same
483 // host, same user, the target channel's own auth_key from the
484 // shared registry). Loopback-only by construction: the caller only
485 // ever forwards to a `local_url` it already verified is loopback.
486 .route("/agentmux/agent/stop", post(handle_agent_stop_forward))
487 // A Swarm broadcast's cross-channel forward target
488 // (SPEC_SWARM_REMOTE_AGENTS_PLATFORM_TAG_AND_SELECTION_2026_10_03.md §5.2):
489 // same trust model as the stop forward above. The caller sends only the
490 // body; this instance writes the header.
491 .route("/agentmux/agent/broadcast", post(handle_agent_broadcast_forward))
492 // The same forward, for an agent's FleetBulkStop: this instance's
493 // user gets the 15 s override window (SPEC_AGENT_SELF_QUIT §6.5).
494 .route("/agentmux/agent/stop-pending", post(handle_agent_stop_pending_forward))
495 .route("/api/messaging/status", get(messaging_handlers::handle_status))
496 .route("/api/messaging/discord/send", post(messaging_handlers::handle_discord_send))
497 .route("/api/messaging/telegram/send", post(messaging_handlers::handle_telegram_send))
498 .route("/api/messaging/slack/send", post(messaging_handlers::handle_slack_send))
499 .route("/api/messaging/whatsapp/send", post(messaging_handlers::handle_whatsapp_send))
500 // Persistent cron scheduler (SPEC_CRON_LOOP_ROBUSTNESS_2026_06_25.md §3.2.4).
501 // Auth-gated like reactive routes.
502 .route("/agentmux/cron", post(cron::handle_cron_create))
503 .route("/agentmux/cron", get(cron::handle_cron_list))
504 .route("/agentmux/cron/:id", delete(cron::handle_cron_delete))
505 .route("/agentmux/cron/:id", patch(cron::handle_cron_patch))
506 // Muxqueue — the universal agent work queue, cron's readiness-triggered
507 // sibling (docs/reports/REPORT_UNIVERSAL_AGENT_WORK_QUEUE_2026_09_01.md).
508 // Same auth gate as the cron routes above.
509 .route("/agentmux/work", post(work_queue::handle_work_enqueue))
510 .route(
511 "/agentmux/identity/fallbacks",
512 get(caller::handle_identity_fallbacks),
513 )
514 .route(
515 "/agentmux/agents/self/keys",
516 get(agent_self_keys::handle_agent_self_keys),
517 )
518 .route("/agentmux/work", get(work_queue::handle_work_list))
519 .route("/agentmux/work/claim", post(work_queue::handle_work_claim))
520 .route("/agentmux/work/:id/heartbeat", post(work_queue::handle_work_heartbeat))
521 .route("/agentmux/work/:id/complete", post(work_queue::handle_work_complete))
522 .route("/agentmux/work/:id/release", post(work_queue::handle_work_release))
523 .route("/agentmux/work/:id", delete(work_queue::handle_work_cancel))
524 .merge(bus_routes)
525 .merge(reactive_routes)
526 // Identity M4a: attribute the (already authenticated) request.
527 .route_layer(middleware::from_fn_with_state(
528 state.clone(),
529 caller::caller_middleware,
530 ))
531 .route_layer(middleware::from_fn_with_state(
532 state.clone(),
533 auth_middleware,
534 ));
535
536 // Health endpoint (no auth). `/health` always; `/` too, except on the
537 // full router when headless `--frontend-dir` serves the frontend there
538 // (`with_frontend`). The LAN router's health never changes.
539 let health = Router::new()
540 .route("/", get(health_handler))
541 .route("/health", get(health_handler));
542 let full_health = if frontend_dir.is_some() {
543 Router::new().route("/health", get(health_handler))
544 } else {
545 health.clone()
546 };
547
548 // WhatsApp Cloud API webhook receiver (no auth). Meta's servers call
549 // these directly and cannot supply the X-AuthKey header auth_middleware
550 // requires, so — like `health` — these must be merged at the top level,
551 // outside `authed_routes`'s `route_layer(auth_middleware)`, not added to
552 // that router. The GET handshake and POST delivery are authenticated by
553 // a different mechanism suited to a third party AgentMux doesn't
554 // control the request format of: hub.verify_token comparison on GET,
555 // and HMAC-SHA256(app_secret, raw_body) signature validation on every
556 // POST (see messaging/whatsapp/webhook.rs). See
557 // docs/specs/SPEC_MESSAGING_INTEGRATION_WHATSAPP_2026_07_07.md §8.2.
558 let whatsapp_webhooks = Router::new().route(
559 "/webhook/whatsapp",
560 get(crate::messaging::whatsapp::handle_verify)
561 .post(crate::messaging::whatsapp::handle_inbound),
562 );
563
564 // Ext 5 of docs/reports/REPORT_MUXSPECT_MUXLOG_CROSS_CHANNEL_INSPECTION_2026_08_22.md:
565 // stamp every response with this instance's own version, so a stale-
566 // build 404 (this session hit exactly this: `muxspect conversations`
567 // 404ing because the running srv predated that route, with nothing
568 // saying so) is self-diagnosing instead of a bare, unexplained 404.
569 // Captured by value (not via AppState extraction) so this applies to
570 // EVERY route uniformly, including the unauthenticated health/webhook
571 // ones, without needing `AppState: Clone`.
572 let version_for_header = state.version.clone();
573 let version_header = axum::middleware::from_fn(move |req: axum::extract::Request, next: axum::middleware::Next| {
574 let version = version_for_header.clone();
575 async move {
576 let mut response = next.run(req).await;
577 if let Ok(value) = axum::http::HeaderValue::from_str(&version) {
578 response.headers_mut().insert("x-agentmux-srv-version", value);
579 }
580 response
581 }
582 });
583
584 let lan = Router::new()
585 .merge(health.clone())
586 .merge(whatsapp_webhooks.clone())
587 .merge(lan_forward_routes.clone())
588 .layer(version_header.clone())
589 .layer(cors.clone())
590 .with_state(state.clone());
591
592 // An approved widget's files (SPEC_USER_WIDGETS_AND_WIDGET_API_2026_10_09.md
593 // §5.2). No auth key: a widget's iframe loads them, and the URL's key
594 // segment is what keeps other pages out. Full router only, never the LAN.
595 let widget_files = Router::new()
596 .route("/agentmux/widget-files/:id/:hash/:key/*path", get(super::widget_handlers::handle_widget_file))
597 .route("/agentmux/widget-sdk/:file", get(super::widget_handlers::handle_widget_sdk));
598
599 let full = Router::new()
600 .merge(full_health)
601 .merge(widget_files)
602 .merge(whatsapp_webhooks)
603 .merge(lan_forward_routes)
604 .merge(authed_routes);
605 // Before the layers: `Router::layer` wraps only the routes and fallback
606 // that exist when it's called, so a fallback added afterwards would skip
607 // CORS and the version header (ReAgent P1 on #3900).
608 let full = match frontend_dir {
609 Some(dir) => with_frontend(full, dir),
610 None => full,
611 };
612 let full = full.layer(version_header).layer(cors).with_state(state);
613
614 SrvRouters { full, lan }
615}
616
617/// Serve the built frontend in `dir` for every path no route claims, `/`
618/// included. Unauthenticated, like the CEF host's static server: the bundle
619/// holds no secrets, and srv's key still guards every API route. Unknown
620/// paths get `index.html`. Full router only; the LAN router never serves it.
621pub(super) fn with_frontend(router: Router<AppState>, dir: &std::path::Path) -> Router<AppState> {
622 use tower_http::services::{ServeDir, ServeFile};
623 let index = ServeFile::new(dir.join("index.html"));
624 router.fallback_service(ServeDir::new(dir).fallback(index))
625}