Skip to main content

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}