agentmux_srv\server/
mod.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4pub(crate) mod cli_handlers;
5mod files;
6mod app_api;
7mod agent_handlers;
8mod editor_handlers;
9mod identity_handlers;
10pub mod install_handlers;
11mod lsp_handlers;
12mod messagebus;
13mod reactive;
14pub(crate) mod service;
15mod shell_handlers;
16mod tool_handlers;
17mod voice;
18pub(crate) mod wave_obj_bridge;
19mod websocket;
20mod drone_handlers;
21mod muxbus_handlers;
22mod native_memory_handlers;
23
24#[cfg(test)]
25pub(crate) mod tests;
26
27use std::sync::Arc;
28
29use axum::{
30    body::Body,
31    extract::{Query, Request, State},
32    http::{header, Method, StatusCode},
33    middleware::{self, Next},
34    response::{IntoResponse, Json, Response},
35    routing::{get, post},
36    Router,
37};
38use serde_json::json;
39use tower_http::cors::{Any, CorsLayer};
40
41use crate::backend::eventbus::EventBus;
42use crate::backend::lan_discovery::LanDiscoveryController;
43use crate::backend::lsp::LspSupervisor;
44use crate::backend::messagebus::MessageBus;
45use crate::backend::reactive::{Poller, ReactiveHandler};
46use crate::backend::storage::filestore::FileStore;
47use crate::backend::storage::store::Store;
48use crate::backend::history::HistoryService;
49use crate::backend::subagent_watcher::SubagentWatcher;
50use crate::backend::wconfig;
51use crate::backend::wps::Broker;
52use agentmux_common::api_types::{
53    PaneTitleRequest, ShellCreateRequest, ShellCreateResponse, ShellStopRequest,
54    TabActivateRequest, TabNameRequest, TabNewRequest, WindowFocusRequest, WindowNameRequest,
55    WorkspaceNameRequest, WpsPublishRequest,
56};
57
58// ---- AppState ----
59
60#[derive(Clone)]
61pub struct AppState {
62    pub auth_key: String,
63    pub version: String,
64    pub app_path: String,
65    pub wstore: Arc<Store>,
66    pub filestore: Arc<FileStore>,
67    /// GLOBAL, channel-independent transcript store backing the
68    /// `agent:<defId>:current` zone. `None` when the shared root can't be
69    /// resolved (global transcripts disabled — falls back to per-channel
70    /// `filestore`). Lets an agent's conversation load when opened from any
71    /// build/channel, finishing the cross-channel arc started by #1387–#1396.
72    /// See `docs/analysis/ANALYSIS_CROSS_CHANNEL_CONVERSATION_HISTORY_2026_06_14.md`.
73    pub global_transcript_store: Option<Arc<FileStore>>,
74    pub event_bus: Arc<EventBus>,
75    pub broker: Arc<Broker>,
76    pub reactive_handler: &'static ReactiveHandler,
77    pub poller: Arc<Poller>,
78    pub config_watcher: Arc<wconfig::ConfigWatcher>,
79    pub messagebus: Arc<MessageBus>,
80    pub subagent_watcher: Arc<SubagentWatcher>,
81    pub history_service: Arc<HistoryService>,
82    /// Tracks every OS-level process each agent CLI has spawned, via
83    /// platform-specific mechanisms (Windows Job Objects, Linux cgroups,
84    /// macOS process groups). Surfaces the tree to the swarm pane and
85    /// provides kill-tree on pane close / host exit.
86    /// See `backend::process_tracker` + `agentmux-ai/AGENT_SPAWNED_PROCESSES_SPEC.md`.
87    pub process_tracker: Arc<crate::backend::process_tracker::registry::AgentProcessRegistry>,
88    /// Live controller for mDNS-based LAN/host peer discovery. The controller
89    /// owns a swappable daemon slot so the `network:lan_discovery` setting can
90    /// be toggled at runtime without restarting the process.
91    /// See `specs/lan-discovery-toggle.md`.
92    pub lan_discovery: Arc<LanDiscoveryController>,
93    /// Language Server Protocol supervisor — owns the lifecycle of LSP
94    /// server child processes (one per workspace/language) and proxies
95    /// LSP messages between the editor pane and the server.
96    /// Spec: `specs/SPEC_EDITOR_LSP_AND_THEMES_2026-05-26.md`.
97    pub lsp_supervisor: Arc<LspSupervisor>,
98    /// Local HTTP URL of this instance (e.g. "http://127.0.0.1:PORT").
99    /// Used for cross-instance inject forwarding and file registry entries.
100    pub local_web_url: String,
101    /// Shared HTTP client for cross-instance inject forwarding.
102    pub http_client: reqwest::Client,
103    /// Phase E.2c.2 — srv reducer's canonical state. Workspace HTTP/WS
104    /// RPC handlers route through the reducer (dispatch
105    /// `Command::Create/Delete/...Workspace` and read out of
106    /// `state.workspaces`); the persist subscriber mirrors emitted
107    /// events back to SQLite. Tab/Block RPC migrations land in
108    /// E.2c.3 / E.2c.4.
109    pub srv_state: std::sync::Arc<tokio::sync::Mutex<crate::state::State>>,
110    /// Phase E.2c.2 — broadcast bus for srv reducer events. RPC
111    /// handlers publish reducer-emitted events here so the persist
112    /// subscriber writes them back to SQLite. Pipe IPC server (when
113    /// bound) shares the same bus.
114    pub srv_events_tx: tokio::sync::broadcast::Sender<agentmux_common::ipc::Event>,
115    /// Phase E.5.5 — monotonic saga-id allocator. Each saga
116    /// (TearOffTab, TearOffBlock, RestoreTornOffTab, etc.) calls
117    /// `fetch_add` to claim a unique id; the id is stamped onto
118    /// `Event::SagaStarted/Completed/Failed` so subscribers can
119    /// correlate. Per-instance scope (no cross-process sharing — see
120    /// `docs/retro/saga-coordinator-location-analysis-2026-04-30.md`).
121    pub saga_id_alloc: std::sync::Arc<std::sync::atomic::AtomicU64>,
122    /// Saga durability — durable on-disk log of saga lifecycle.
123    /// Written by `SagaCtx::dispatch` / `compensate` (per-step) and
124    /// `emit_terminal` (per-saga) so a srv crash mid-saga leaves a
125    /// recoverable trail. PR 1 ships the log + instrumentation; PR 2
126    /// adds resume-on-startup + `--diag sagas`.
127    /// See `docs/specs/SPEC_SAGA_DURABILITY_2026-05-01.md`.
128    pub saga_log: std::sync::Arc<crate::sagas::log::SagaLog>,
129    /// Pre-launch OAuth session state — one entry per in-flight
130    /// "Connect with OAuth" attempt from the launch modal. See
131    /// `docs/specs/SPEC_PRE_LAUNCH_OAUTH_FLOW_2026_05_14.md`.
132    pub auth_session_manager: std::sync::Arc<crate::identity::auth_session::AuthSessionManager>,
133
134    /// In-flight `install.start` sessions. Frontend subscribes to
135    /// `install_chunk` WPS events scoped by session id; the registry
136    /// holds per-session cancel handles so `install.cancel` can abort
137    /// an install mid-flight.
138    /// See `SPEC_AGENT_INSTALL_STAGE_2026_05_17.md` §9.
139    pub install_sessions: std::sync::Arc<crate::server::install_handlers::InstallSessionRegistry>,
140    /// Docker container manager for container-type agent panes (Phase 2).
141    /// `None` when Docker is not available on this host — container agents
142    /// will refuse to start rather than crashing the server.
143    pub container_manager: Option<std::sync::Arc<crate::backend::container::ContainerManager>>,
144    /// Phase 3 — per-shell stop handles so `ShellStop` (MCP tool) and the UI
145    /// stop button can tree-kill a running persistent shell node. See
146    /// `docs/specs/SPEC_PERSISTENT_SHELL_PHASE3_STOP_2026_06_14.md`.
147    pub shell_sessions: std::sync::Arc<crate::backend::shell_node::ShellSessionRegistry>,
148}
149
150/// Build the Axum router with all routes, auth middleware, and CORS.
151pub fn build_router(state: AppState) -> Router {
152    // CORS: reflect only loopback origins.
153    //
154    // Before the 2026-05-11 security audit (C3) this allowed any origin
155    // (matching the historical Go pkg/web/web.go). That made every web
156    // page the user happened to have open a potential CSRF source —
157    // localhost is not a trust boundary on a developer machine.
158    //
159    // The legitimate cross-origin callers are:
160    //   - The CEF frontend served from `http://127.0.0.1:<host-port>`
161    //   - Vite dev server at `http://localhost:5173` (and similar)
162    //
163    // Both are loopback. The predicate accepts http://127.0.0.1:* and
164    // http://localhost:* (any port, http only; https is irrelevant for
165    // loopback). External origins are denied, which means a malicious
166    // page in the user's browser can't drive the sidecar even if it
167    // discovers the port.
168    use tower_http::cors::AllowOrigin;
169    let cors = CorsLayer::new()
170        .allow_origin(AllowOrigin::predicate(|origin, _req| {
171            let Ok(s) = origin.to_str() else { return false };
172            s.starts_with("http://127.0.0.1:")
173                || s.starts_with("http://localhost:")
174                || s == "http://127.0.0.1"
175                || s == "http://localhost"
176        }))
177        .allow_methods(Any)
178        .allow_headers(vec![
179            header::CONTENT_TYPE,
180            header::AUTHORIZATION,
181            header::ACCEPT,
182            "X-Session-Id".parse().unwrap(),
183            "X-AuthKey".parse().unwrap(),
184            "X-Requested-With".parse().unwrap(),
185            "x-vercel-ai-ui-message-stream".parse().unwrap(),
186        ]);
187
188    // Reactive routes. Previously registered without auth on the
189    // assumption that localhost is a trust boundary; the 2026-05-11
190    // security audit (C1 + C2) showed that any local process — or a
191    // web page driving 127.0.0.1 via the permissive CORS layer — could
192    // drive `/agentmux/reactive/inject` and reconfigure the cloud
193    // agentbus poller. These routes are now merged into `authed_routes`
194    // below and gated by `auth_middleware`.
195    let reactive_routes = Router::new()
196        .route("/agentmux/reactive/inject", post(reactive::handle_reactive_inject))
197        .route("/agentmux/reactive/agents", get(reactive::handle_reactive_agents))
198        .route("/agentmux/reactive/agent", get(reactive::handle_reactive_agent))
199        .route("/agentmux/reactive/audit", get(reactive::handle_reactive_audit))
200        .route("/agentmux/reactive/register", post(reactive::handle_reactive_register))
201        .route(
202            "/agentmux/reactive/unregister",
203            post(reactive::handle_reactive_unregister),
204        )
205        .route(
206            "/agentmux/reactive/poller/stats",
207            get(reactive::handle_reactive_poller_stats),
208        )
209        .route(
210            "/agentmux/reactive/poller/config",
211            post(reactive::handle_reactive_poller_config),
212        )
213        .route(
214            "/agentmux/reactive/poller/status",
215            get(reactive::handle_reactive_poller_status),
216        );
217
218    // MessageBus routes (authed, localhost-only)
219    let bus_routes = Router::new()
220        .route("/api/bus/register", post(messagebus::handle_register))
221        .route("/api/bus/send", post(messagebus::handle_send))
222        .route("/api/bus/inject", post(messagebus::handle_inject))
223        .route("/api/bus/broadcast", post(messagebus::handle_broadcast))
224        .route("/api/bus/messages", get(messagebus::handle_read_messages))
225        .route("/api/bus/messages/delete", post(messagebus::handle_delete_messages))
226        .route("/api/bus/agents", get(messagebus::handle_list_agents));
227
228    let authed_routes = Router::new()
229        .route("/ws", get(websocket::handle_ws))
230        .route("/agentmux/service", post(service::handle_service))
231        .route("/agentmux/file", get(files::handle_wave_file))
232        .route("/agentmux/stream-file", get(stub_501))
233        .route("/agentmux/stream-file/*path", get(stub_501))
234        .route("/agentmux/stream-local-file", get(stub_501))
235        .route("/api/post-chat-message", get(stub_501).post(stub_501))
236        .route("/docsite/*path", get(files::handle_docsite))
237        .route("/schema/*path", get(files::handle_schema))
238        .route("/api/lan-instances", get(handle_lan_instances))
239        .route("/agentmux/discovery", get(handle_discovery))
240        .route("/agentmux/diag/sagas", get(handle_diag_sagas))
241        // Streaming-bash wrapper publish endpoint
242        // (SPEC_STREAMING_BASH_RUNNER_2026_05_11.md §4.3). agentmux-bashwrap
243        // POSTs `{event, scopes, data}` here while a PreToolUse-rewritten
244        // Bash command is running; we forward to the in-process WPS broker.
245        // Auth-gated like the other reactive routes (PR #801 pattern).
246        .route("/agentmux/wps/publish", post(handle_wps_publish))
247        // Persistent shell launch endpoint
248        // (SPEC_PERSISTENT_SHELL_NODE_2026_06_11.md §5.3). agentmux-mcp's
249        // Shell tool POSTs here; we publish shell_node_create + spawn a
250        // ShellNodeRunner that streams shell_chunk events to the frontend.
251        .route("/api/v1/shell/create", post(handle_shell_create))
252        // Stop a persistent shell (Phase 3). agentmux-mcp's `ShellStop` tool
253        // POSTs here; tree-kills the shell's process group.
254        .route("/api/v1/shell/stop", post(handle_shell_stop))
255        // Open a pane (editor/term/browser/…) from an agent tool call.
256        // agentmux-mcp's OpenEditor tool POSTs `{view:"editor", file, …}` here;
257        // shares the exact pane.open logic with the WebSocket RPC handler
258        // (app_api::open_pane). See ANALYSIS_AGENT_APP_API_OPEN_IN_EDITOR_2026_05_30.
259        .route("/api/v1/pane/open", post(handle_pane_open))
260        // Voice speech-to-text: the renderer POSTs mic audio (one
261        // silence-bounded utterance per request); we forward to a Whisper
262        // backend and return the transcript. Key stays server-side.
263        // See SPEC_VOICE_STT_ENGINE_2026_06_20.md and #1591.
264        .route("/api/v1/voice/transcribe", post(voice::handle_voice_transcribe))
265        // First-class agent API (SPEC_AGENT_API_FIRST_CLASS_SURFACE_2026_06_17.md).
266        // `GET /api/v1/self?block_id=` resolves the caller's place in the tree;
267        // `POST /api/v1/window/name` sets the window display name (taskbar title).
268        // agentmux-mcp's `WhoAmI` / `SetWindowName` tools call these.
269        .route("/api/v1/self", get(handle_self))
270        .route("/api/v1/window/name", post(handle_window_name))
271        // Naming verbs (SPEC §4.3): rename the caller's own tab / pane / workspace
272        // (or an explicit target). agentmux-mcp's SetTabName / SetPaneTitle /
273        // SetWorkspaceName tools POST here.
274        .route("/api/v1/tab/name", post(handle_tab_name))
275        .route("/api/v1/pane/title", post(handle_pane_title))
276        .route("/api/v1/workspace/name", post(handle_workspace_name))
277        // Introspection verbs (SPEC §4.6): read-only views of the UI tree so an
278        // agent can see what's around it. agentmux-mcp's GetLayout / ListWindows
279        // / ListWorkspaces / ListTabs tools GET these.
280        .route("/api/v1/layout", get(handle_layout))
281        .route("/api/v1/windows", get(handle_list_windows))
282        .route("/api/v1/workspaces", get(handle_list_workspaces))
283        .route("/api/v1/tabs", get(handle_list_tabs))
284        // Layout / navigation verbs (SPEC §4.5): switch the active tab, open a
285        // new tab, focus a window. agentmux-mcp's SetActiveTab / NewTab /
286        // FocusWindow tools POST here.
287        .route("/api/v1/tab/activate", post(handle_tab_activate))
288        .route("/api/v1/tab/new", post(handle_tab_new))
289        .route("/api/v1/window/focus", post(handle_window_focus))
290        .merge(bus_routes)
291        .merge(reactive_routes)
292        .route_layer(middleware::from_fn_with_state(
293            state.clone(),
294            auth_middleware,
295        ));
296
297    // Health endpoint (no auth)
298    let health = Router::new().route("/", get(health_handler));
299
300    Router::new()
301        .merge(health)
302        .merge(authed_routes)
303        .layer(cors)
304        .with_state(state)
305}
306
307// ---- Health ----
308
309async fn health_handler(State(state): State<AppState>) -> Json<serde_json::Value> {
310    Json(json!({
311        "status": "ok",
312        "version": state.version,
313    }))
314}
315
316/// Saga durability PR 2 — operator visibility into the durable saga
317/// log. Returns the most-recent 50 saga lifecycle rows + an in-flight
318/// count derived from `unresolved_sagas`.
319///
320/// **Why a JSON HTTP endpoint and not a launcher `--diag sagas`
321/// pipe-IPC client.** The `--diag srv` pipe transport (see
322/// `agentmux-launcher/src/diag.rs`) routes through `Tool` registration
323/// + a 2 s observation window with `GetSrvSnapshot` + `GetEvents`.
324/// Adding `GetSagaLogSnapshot` to the IPC `Command` enum + an
325/// `Event::SagaLogSnapshot` variant with a Vec of `SagaSnapshot`
326/// triples the touched-files surface for one operator command.
327/// JSON HTTP is the precedent for raw operator queries (cf
328/// `/api/lan-instances`) and matches the spec §9 PR 2 phrasing
329/// "tightened scope". Promoting to first-class `--diag sagas` via
330/// pipe IPC is a follow-up if anyone asks.
331///
332/// Operator workflow today:
333/// ```text
334/// curl -s -H "X-AuthKey: $KEY" http://127.0.0.1:$PORT/agentmux/diag/sagas | jq .
335/// ```
336/// Response shape:
337/// ```json
338/// {
339///   "recent": [ { "saga_id": ..., "name": ..., "state": ..., ... }, ... ],
340///   "in_flight_count": 1,
341///   "recently_failed_count": 0,
342///   "total_returned": 50
343/// }
344/// ```
345async fn handle_diag_sagas(State(state): State<AppState>) -> Json<serde_json::Value> {
346    const LIMIT: u32 = 50;
347    let recent = match state.saga_log.snapshot_recent(LIMIT) {
348        Ok(rows) => rows,
349        Err(e) => {
350            return Json(json!({
351                "error": format!("snapshot_recent failed: {}", e),
352            }));
353        }
354    };
355    let in_flight = match state.saga_log.unresolved_sagas() {
356        Ok(rows) => rows.len(),
357        Err(e) => {
358            tracing::warn!("[diag/sagas] unresolved_sagas failed: {}", e);
359            0
360        }
361    };
362    let recently_failed = recent
363        .iter()
364        .filter(|s| s.state == "failed" || s.state == "failed_compensation")
365        .count();
366    Json(json!({
367        "recent": recent,
368        "in_flight_count": in_flight,
369        "recently_failed_count": recently_failed,
370        "total_returned": recent.len(),
371    }))
372}
373
374async fn handle_lan_instances(State(state): State<AppState>) -> Json<serde_json::Value> {
375    Json(json!(state.lan_discovery.get_instances()))
376}
377
378/// `GET /agentmux/discovery` — a unified, agent-facing view of what exists and
379/// what is reachable across the muxbus delivery tiers, so an agent can resolve a
380/// target before sending. Aggregates:
381///   - `host.addressable`: the authoritative Tier-1/2 reachable set
382///     (`reactive_handler.list_agents()` — every entry has a live block_id).
383///   - `host.agents`: this host's agent directory (SQLite `instance_list`),
384///     each flagged `addressable` iff its name is in the reachable set.
385///   - `lan`: Tier-3 mDNS peers (each `LanInstance` carries its own `agents`).
386///   - `wan.subscribed_agents`: Tier-4 cloud subscriptions (empty when no token).
387/// Addressing is case-insensitive (registration lowercases the key). Authed like
388/// the other reactive routes; agents reach it via AGENTMUX_LOCAL_URL + X-AuthKey.
389/// See SPEC_MUXBUS_AGENT_DISCOVERY_AND_PERSISTENT_DELIVERY_2026_06_16.
390async fn handle_discovery(State(state): State<AppState>) -> Json<serde_json::Value> {
391    // Tier-1/2 reachable set — the authoritative "addressable" answer. Keep the
392    // live block_id per name (lowercased) so an addressable row can surface its
393    // real delivery block; the SQLite directory below does not carry one.
394    let reachable = state.reactive_handler.list_agents();
395    let reachable_block: std::collections::HashMap<String, String> = reachable
396        .iter()
397        .map(|a| (a.agent_id.to_lowercase(), a.block_id.clone()))
398        .collect();
399
400    // Host directory (live SQLite instances). `instance_list` already excludes
401    // hidden (user_hidden = 0) and template rows in SQL, and the consolidated
402    // path leaves block_id/status empty (agents.rs) — so addressability AND the
403    // live block_id come from the reachable set above. `block_id` is null for a
404    // known-but-unreachable agent; the always-empty `status` is omitted.
405    let instances = state.wstore.instance_list(None, None).unwrap_or_default();
406    let agents: Vec<serde_json::Value> = instances
407        .into_iter()
408        .map(|i| {
409            let live_block = if i.instance_name.is_empty() {
410                None
411            } else {
412                reachable_block.get(&i.instance_name.to_lowercase()).cloned()
413            };
414            json!({
415                "name": i.instance_name,
416                "id": i.id,
417                "definition_id": i.definition_id,
418                "working_directory": i.working_directory,
419                "addressable": live_block.is_some(),
420                "block_id": live_block,
421            })
422        })
423        .collect();
424
425    let wan_agents = crate::muxbus::cloud_subscriber::get_global_subscriber()
426        .map(|s| s.subscribed_agents())
427        .unwrap_or_default();
428
429    let lan = state.lan_discovery.get_instances();
430    let version = state.version.clone();
431    let local_url = state.local_web_url.clone();
432
433    Json(json!({
434        "host": {
435            "version": version,
436            "local_url": local_url,
437            "addressable": reachable,
438            "agents": agents,
439        },
440        "lan": lan,
441        "wan": { "subscribed_agents": wan_agents },
442    }))
443}
444
445async fn stub_501() -> impl IntoResponse {
446    (
447        StatusCode::NOT_IMPLEMENTED,
448        Json(json!({"error": "not implemented"})),
449    )
450}
451
452/// Auth-gated WPS publish endpoint
453/// (SPEC_STREAMING_BASH_RUNNER_2026_05_11.md §3.2). `agentmux-bashwrap`
454/// POSTs here while running a Bash command; we forward to the
455/// in-process WPS broker so subscribed frontends receive the event.
456async fn handle_wps_publish(
457    State(state): State<AppState>,
458    Json(req): Json<WpsPublishRequest>,
459) -> impl IntoResponse {
460    let event = crate::backend::wps::WaveEvent {
461        event: req.event,
462        scopes: req.scopes,
463        sender: String::new(),
464        persist: req.persist,
465        data: Some(req.data),
466    };
467    state.broker.publish(event);
468    (StatusCode::OK, Json(json!({"ok": true})))
469}
470
471/// `POST /api/v1/shell/create` — start a persistent background shell.
472///
473/// Called by `agentmux-mcp`'s `Shell` tool. Returns immediately with a
474/// `shell_id`; the `ShellNodeRunner` streams stdout/stderr to the frontend
475/// as `shell_chunk` WPS events without blocking the agent.
476async fn handle_shell_create(
477    State(state): State<AppState>,
478    Json(req): Json<ShellCreateRequest>,
479) -> impl IntoResponse {
480    let shell_id = uuid::Uuid::new_v4().to_string();
481    let title = req.title.as_deref().unwrap_or(&req.cmd).to_string();
482    let now_ms = std::time::SystemTime::now()
483        .duration_since(std::time::UNIX_EPOCH)
484        .unwrap_or_default()
485        .as_millis() as u64;
486
487    // Read the agent block once for both the cwd and env fallbacks below.
488    let agent_block = state.wstore
489        .get::<crate::backend::obj::Block>(&req.agent_block_id)
490        .ok()
491        .flatten();
492
493    // If cwd wasn't supplied by the caller, fall back to the agent block's
494    // cmd:cwd — the working directory the agent pane was launched with.
495    // Without this, ShellNodeRunner would inherit agentmux-srv's cwd
496    // (typically the portable runtime/ dir) instead of the project dir.
497    let effective_cwd = req.cwd.or_else(|| {
498        agent_block.as_ref().and_then(|block| {
499            let cwd = crate::backend::obj::meta_get_string(&block.meta, "cmd:cwd", "");
500            if cwd.is_empty() { None } else { Some(cwd.to_string()) }
501        })
502    });
503
504    // Normalize the cwd before it reaches the spawner. Agents on Windows run
505    // inside a bash shell and emit MSYS paths like `/c/Users/asafe/project`;
506    // passing those straight to `Command::current_dir` fails with os error 267
507    // (ERROR_DIRECTORY). This converts them to native form and expands `~`.
508    let effective_cwd =
509        effective_cwd.and_then(|c| crate::backend::base::normalize_working_dir(&c));
510
511    // Env parity with the agent CLI: start from the agent block's stored
512    // cmd:env (the per-agent env the agent process is launched with — same
513    // shape app_api.rs / websocket.rs read), then let the caller-supplied
514    // req.env override on top (explicit Shell env wins). This forwards the
515    // concrete per-agent env so `Shell(...)` runs with the same env the agent
516    // itself sees, mirroring the cmd:cwd fallback above.
517    //
518    // NOT forwarded here: the dynamic identity bindings (resolver.rs) and the
519    // bundled tools/bin PATH prefix that blockcontroller/shell.rs injects at
520    // agent-CLI spawn time — those are resolved live per spawn, not stored in
521    // cmd:env. The MCP server (agentmux-mcp) is itself launched by the agent
522    // CLI through the bundled tools/bin, so tools it spawns inherit that PATH;
523    // shells created here run from agentmux-srv's env plus cmd:env + req.env.
524    let mut effective_env: std::collections::HashMap<String, String> = agent_block
525        .as_ref()
526        .and_then(|block| match block.meta.get("cmd:env") {
527            Some(serde_json::Value::Object(obj)) => Some(
528                obj.iter()
529                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
530                    .collect(),
531            ),
532            _ => None,
533        })
534        .unwrap_or_default();
535    if let Some(req_env) = req.env {
536        effective_env.extend(req_env);
537    }
538
539    tracing::info!(
540        block_id = %req.agent_block_id,
541        shell_id = %shell_id,
542        cmd = %req.cmd,
543        cwd = ?effective_cwd,
544        "shell.create"
545    );
546
547    // Publish shell_node_create so the frontend inserts the row
548    // before the first chunk arrives (avoids a flash of orphaned chunks).
549    // persist: 64 — retain up to 64 shell_node_create events per block scope
550    // so multiple shells in a pane all replay on WS reconnect / pane remount.
551    // (persist: 1 meant only the last shell's create event was kept; earlier
552    // shells lost their create event while their shell_chunk events at
553    // persist: 1024 still replayed, causing the reducer to silently drop
554    // orphaned chunks.)
555    state.broker.publish(crate::backend::wps::WaveEvent {
556        event: crate::backend::wps::EVENT_SHELL_NODE_CREATE.to_string(),
557        scopes: vec![format!("block:{}", req.agent_block_id)],
558        sender: String::new(),
559        persist: 64,
560        data: Some(json!({
561            "shell_id": shell_id,
562            "cmd": req.cmd,
563            "cwd": effective_cwd,
564            "title": title,
565            "timestamp": now_ms,
566        })),
567    });
568
569    // Spawn the runner — fire-and-forget; events flow independently.
570    let runner = crate::backend::shell_node::ShellNodeRunner {
571        shell_id: shell_id.clone(),
572        block_id: req.agent_block_id,
573        cmd: req.cmd,
574        cwd: effective_cwd,
575        extra_env: effective_env,
576        broker: Arc::clone(&state.broker),
577        registry: Arc::clone(&state.shell_sessions),
578    };
579    tokio::spawn(runner.run());
580
581    (StatusCode::OK, Json(ShellCreateResponse { shell_id }))
582}
583
584/// `POST /api/v1/shell/stop` — stop a running persistent shell.
585///
586/// Called by `agentmux-mcp`'s `ShellStop` tool. Tree-kills the shell's
587/// process group (so `task dev` → `task.exe`/`node` grandchildren die too),
588/// which makes the runner publish a `stopped` exit event. Returns `{ stopped }`
589/// — false if the id is unknown (never started or already exited).
590async fn handle_shell_stop(
591    State(state): State<AppState>,
592    Json(req): Json<ShellStopRequest>,
593) -> impl IntoResponse {
594    let stopped = state.shell_sessions.stop(&req.shell_id);
595    tracing::info!(shell_id = %req.shell_id, stopped, "shell.stop");
596    (StatusCode::OK, Json(json!({ "stopped": stopped })))
597}
598
599/// `POST /api/v1/pane/open` — open a pane (editor/term/browser/…).
600///
601/// Called by `agentmux-mcp`'s `OpenEditor` tool. Thin HTTP wrapper over
602/// `app_api::open_pane` (the same logic the WebSocket `pane.open` RPC uses):
603/// creates the block, enqueues the layout action, and broadcasts the updates
604/// so the frontend renders the pane. Body is `CommandPaneOpenData`.
605async fn handle_pane_open(
606    State(state): State<AppState>,
607    Json(req): Json<crate::backend::rpc_types::CommandPaneOpenData>,
608) -> impl IntoResponse {
609    match app_api::open_pane(&state, req).await {
610        Ok(result) => (StatusCode::OK, Json(json!(result))).into_response(),
611        Err(e) => {
612            // Argument/validation errors from build_pane_meta are the caller's
613            // fault (400); everything else is a server-side failure (500).
614            let status = if e.starts_with("MISSING_ARG") || e.starts_with("INVALID_VIEW") {
615                StatusCode::BAD_REQUEST
616            } else {
617                StatusCode::INTERNAL_SERVER_ERROR
618            };
619            (status, Json(json!({ "error": e }))).into_response()
620        }
621    }
622}
623
624#[derive(serde::Deserialize)]
625struct SelfQuery {
626    /// Block UUID of the calling agent pane (its `AGENTMUX_BLOCKID`).
627    block_id: Option<String>,
628}
629
630/// `GET /api/v1/self?block_id=<id>` — resolve the calling agent's place in the
631/// object tree (block → tab → window → workspace, with their names). The
632/// sidecar serves many agents, so the caller identifies itself by its block id
633/// (the MCP `WhoAmI` tool passes `AGENTMUX_BLOCKID`). Naming verbs reuse the
634/// same resolver to default their target to the agent's own context.
635async fn handle_self(
636    State(state): State<AppState>,
637    Query(q): Query<SelfQuery>,
638) -> impl IntoResponse {
639    let block_id = q.block_id.unwrap_or_default();
640    if block_id.is_empty() {
641        return (StatusCode::BAD_REQUEST, Json(json!({ "error": "missing block_id" }))).into_response();
642    }
643    match service::resolve_agent_context(&state.wstore, &block_id) {
644        Ok(ctx) => Json(serde_json::to_value(&ctx).unwrap_or_default()).into_response(),
645        Err(e) => (StatusCode::NOT_FOUND, Json(json!({ "error": e }))).into_response(),
646    }
647}
648
649/// `POST /api/v1/window/name` — set a window's display name
650/// (`window:displayname`), which the frontend turns into the OS/taskbar title.
651/// Defaults to the caller's own window (resolved from `block_id`). Routes
652/// through the same `object.UpdateObjectMeta` service path the InstancePanel
653/// rename uses, so persistence + live-title update are identical.
654/// agentmux-mcp's `SetWindowName` tool POSTs here.
655async fn handle_window_name(
656    State(state): State<AppState>,
657    Json(req): Json<WindowNameRequest>,
658) -> impl IntoResponse {
659    // window:displayname is documented as ≤64 chars (window-title.ts).
660    let name: String = req.name.trim().chars().take(64).collect();
661    if name.is_empty() {
662        return (StatusCode::BAD_REQUEST, Json(json!({ "error": "name must not be empty" }))).into_response();
663    }
664
665    let window_id = match req.window_id.filter(|w| !w.is_empty()) {
666        Some(w) => w,
667        None => {
668            let block_id = req.block_id.unwrap_or_default();
669            if block_id.is_empty() {
670                return (StatusCode::BAD_REQUEST, Json(json!({ "error": "provide window_id or block_id" }))).into_response();
671            }
672            match service::resolve_agent_context(&state.wstore, &block_id) {
673                Ok(ctx) => match ctx.window_id {
674                    Some(w) => w,
675                    None => {
676                        return (
677                            StatusCode::NOT_FOUND,
678                            Json(json!({ "error": "no live window for this agent (tab not attached to a window)" })),
679                        )
680                            .into_response()
681                    }
682                },
683                Err(e) => return (StatusCode::NOT_FOUND, Json(json!({ "error": e }))).into_response(),
684            }
685        }
686    };
687
688    let call = crate::backend::service::WebCallType {
689        service: "object".to_string(),
690        method: "UpdateObjectMeta".to_string(),
691        uicontext: None,
692        args: vec![
693            json!(format!("window:{window_id}")),
694            json!({ "window:displayname": name }),
695        ],
696    };
697    let result = service::run_service_call(&state, &call).await;
698    if let Some(err) = result.error {
699        return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err }))).into_response();
700    }
701    Json(json!({ "success": true, "window_id": window_id, "name": name })).into_response()
702}
703
704/// Trim a user-supplied name and clamp to `max` chars; `None` if empty.
705fn clean_name(raw: &str, max: usize) -> Option<String> {
706    let n: String = raw.trim().chars().take(max).collect();
707    if n.is_empty() {
708        None
709    } else {
710        Some(n)
711    }
712}
713
714/// `POST /api/v1/tab/name` — rename a tab. Defaults to the caller's own tab
715/// (resolved from `block_id`). Routes through `object.UpdateTabName`.
716async fn handle_tab_name(
717    State(state): State<AppState>,
718    Json(req): Json<TabNameRequest>,
719) -> impl IntoResponse {
720    let name = match clean_name(&req.name, 128) {
721        Some(n) => n,
722        None => return (StatusCode::BAD_REQUEST, Json(json!({ "error": "name must not be empty" }))).into_response(),
723    };
724    let tab_id = match req.tab_id.filter(|t| !t.is_empty()) {
725        Some(t) => t,
726        None => match resolve_own(&state, req.block_id, |c| Some(c.tab_id.clone())) {
727            Ok(t) => t,
728            Err(resp) => return resp,
729        },
730    };
731    let call = crate::backend::service::WebCallType {
732        service: "object".to_string(),
733        method: "UpdateTabName".to_string(),
734        uicontext: None,
735        args: vec![json!(tab_id), json!(name)],
736    };
737    finish_name_call(&state, call, json!({ "success": true, "tab_id": tab_id, "name": name })).await
738}
739
740/// `POST /api/v1/pane/title` — set a pane's display title (`frame:title`).
741/// Targets the caller's own pane (its `block_id`). Routes through
742/// `object.UpdateObjectMeta`.
743async fn handle_pane_title(
744    State(state): State<AppState>,
745    Json(req): Json<PaneTitleRequest>,
746) -> impl IntoResponse {
747    let title = match clean_name(&req.title, 128) {
748        Some(t) => t,
749        None => return (StatusCode::BAD_REQUEST, Json(json!({ "error": "title must not be empty" }))).into_response(),
750    };
751    let block_id = match req.block_id.filter(|b| !b.is_empty()) {
752        Some(b) => b,
753        None => return (StatusCode::BAD_REQUEST, Json(json!({ "error": "missing block_id" }))).into_response(),
754    };
755    let call = crate::backend::service::WebCallType {
756        service: "object".to_string(),
757        method: "UpdateObjectMeta".to_string(),
758        uicontext: None,
759        args: vec![
760            json!(format!("block:{block_id}")),
761            json!({ "frame:title": title }),
762        ],
763    };
764    finish_name_call(&state, call, json!({ "success": true, "block_id": block_id, "title": title })).await
765}
766
767/// `POST /api/v1/workspace/name` — rename a workspace. Defaults to the
768/// caller's own workspace (resolved from `block_id`). Routes through
769/// `workspace.UpdateWorkspace`.
770async fn handle_workspace_name(
771    State(state): State<AppState>,
772    Json(req): Json<WorkspaceNameRequest>,
773) -> impl IntoResponse {
774    let name = match clean_name(&req.name, 128) {
775        Some(n) => n,
776        None => return (StatusCode::BAD_REQUEST, Json(json!({ "error": "name must not be empty" }))).into_response(),
777    };
778    let workspace_id = match req.workspace_id.filter(|w| !w.is_empty()) {
779        Some(w) => w,
780        None => match resolve_own(&state, req.block_id, |c| c.workspace_id.clone()) {
781            Ok(w) => w,
782            Err(resp) => return resp,
783        },
784    };
785    let call = crate::backend::service::WebCallType {
786        service: "workspace".to_string(),
787        method: "UpdateWorkspace".to_string(),
788        uicontext: None,
789        args: vec![json!(workspace_id), json!(name)],
790    };
791    finish_name_call(&state, call, json!({ "success": true, "workspace_id": workspace_id, "name": name })).await
792}
793
794/// Resolve a target id from the caller's own context (via `block_id`), using
795/// `pick` to select the field. Returns the route's error response on failure
796/// (missing block_id, unresolvable block, or the field is `None`).
797fn resolve_own(
798    state: &AppState,
799    block_id: Option<String>,
800    pick: impl Fn(&service::AgentContext) -> Option<String>,
801) -> Result<String, Response> {
802    let block_id = block_id.unwrap_or_default();
803    if block_id.is_empty() {
804        return Err((StatusCode::BAD_REQUEST, Json(json!({ "error": "provide an explicit target id or block_id" }))).into_response());
805    }
806    let ctx = service::resolve_agent_context(&state.wstore, &block_id)
807        .map_err(|e| (StatusCode::NOT_FOUND, Json(json!({ "error": e }))).into_response())?;
808    pick(&ctx).filter(|s| !s.is_empty()).ok_or_else(|| {
809        (
810            StatusCode::NOT_FOUND,
811            Json(json!({ "error": "no such target resolved for this agent" })),
812        )
813            .into_response()
814    })
815}
816
817/// Run a naming service call and map the result to a JSON HTTP response.
818async fn finish_name_call(
819    state: &AppState,
820    call: crate::backend::service::WebCallType,
821    ok_body: serde_json::Value,
822) -> Response {
823    let result = service::run_service_call(state, &call).await;
824    if let Some(err) = result.error {
825        return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err }))).into_response();
826    }
827    Json(ok_body).into_response()
828}
829
830/// `GET /api/v1/layout` — read-only window → workspace → tab → pane tree.
831async fn handle_layout(State(state): State<AppState>) -> impl IntoResponse {
832    Json(service::agent_layout(&state.wstore))
833}
834
835/// `GET /api/v1/windows` — flat list of windows.
836async fn handle_list_windows(State(state): State<AppState>) -> impl IntoResponse {
837    Json(service::agent_windows(&state.wstore))
838}
839
840/// `GET /api/v1/workspaces` — flat list of workspaces.
841async fn handle_list_workspaces(State(state): State<AppState>) -> impl IntoResponse {
842    Json(service::agent_workspaces(&state.wstore))
843}
844
845#[derive(serde::Deserialize)]
846struct ListTabsQuery {
847    /// Limit to this workspace's tabs. Omit (or pass `block_id`) for all tabs.
848    #[serde(default)]
849    workspace_id: Option<String>,
850    /// Calling agent's block id — scopes to the caller's own workspace when
851    /// `workspace_id` is omitted.
852    #[serde(default)]
853    block_id: Option<String>,
854}
855
856/// `GET /api/v1/tabs` — flat list of tabs, optionally scoped to a workspace
857/// (explicit `workspace_id`, or the caller's own via `block_id`).
858async fn handle_list_tabs(
859    State(state): State<AppState>,
860    Query(q): Query<ListTabsQuery>,
861) -> impl IntoResponse {
862    let ws_id = q.workspace_id.filter(|w| !w.is_empty()).or_else(|| {
863        q.block_id
864            .filter(|b| !b.is_empty())
865            .and_then(|b| service::resolve_agent_context(&state.wstore, &b).ok())
866            .and_then(|ctx| ctx.workspace_id)
867    });
868    Json(service::agent_tabs(&state.wstore, ws_id.as_deref()))
869}
870
871/// `POST /api/v1/tab/activate` — make `tab_id` the active tab in its
872/// workspace. Routes through `workspace.SetActiveTab`.
873async fn handle_tab_activate(
874    State(state): State<AppState>,
875    Json(req): Json<TabActivateRequest>,
876) -> impl IntoResponse {
877    let tab_id = req.tab_id.trim().to_string();
878    if tab_id.is_empty() {
879        return (StatusCode::BAD_REQUEST, Json(json!({ "error": "missing tab_id" }))).into_response();
880    }
881    let ws_id = match service::workspace_id_for_tab(&state.wstore, &tab_id) {
882        Some(w) => w,
883        None => return (StatusCode::NOT_FOUND, Json(json!({ "error": format!("no workspace owns tab {tab_id}") }))).into_response(),
884    };
885    let call = crate::backend::service::WebCallType {
886        service: "workspace".to_string(),
887        method: "SetActiveTab".to_string(),
888        uicontext: None,
889        args: vec![json!(ws_id), json!(tab_id)],
890    };
891    finish_name_call(&state, call, json!({ "success": true, "tab_id": tab_id })).await
892}
893
894/// `POST /api/v1/tab/new` — create (and activate) a new tab in the caller's
895/// workspace (or an explicit `workspace_id`). Routes through
896/// `workspace.CreateTab`.
897async fn handle_tab_new(
898    State(state): State<AppState>,
899    Json(req): Json<TabNewRequest>,
900) -> impl IntoResponse {
901    let name = req.name.map(|n| n.trim().chars().take(128).collect::<String>()).unwrap_or_default();
902    let workspace_id = match req.workspace_id.filter(|w| !w.is_empty()) {
903        Some(w) => w,
904        None => match resolve_own(&state, req.block_id, |c| c.workspace_id.clone()) {
905            Ok(w) => w,
906            Err(resp) => return resp,
907        },
908    };
909    let call = crate::backend::service::WebCallType {
910        service: "workspace".to_string(),
911        method: "CreateTab".to_string(),
912        uicontext: None,
913        // [ws_id, name, activate]; empty name → backend auto-names tab{N}.
914        args: vec![json!(workspace_id), json!(name), json!(true)],
915    };
916    finish_name_call(&state, call, json!({ "success": true, "workspace_id": workspace_id })).await
917}
918
919/// `POST /api/v1/window/focus` — bring a window to the foreground. Defaults to
920/// the caller's own window. Routes through `client.FocusWindow`.
921async fn handle_window_focus(
922    State(state): State<AppState>,
923    Json(req): Json<WindowFocusRequest>,
924) -> impl IntoResponse {
925    let window_id = match req.window_id.filter(|w| !w.is_empty()) {
926        Some(w) => w,
927        None => match resolve_own(&state, req.block_id, |c| c.window_id.clone()) {
928            Ok(w) => w,
929            Err(resp) => return resp,
930        },
931    };
932    let call = crate::backend::service::WebCallType {
933        service: "client".to_string(),
934        method: "FocusWindow".to_string(),
935        uicontext: None,
936        args: vec![json!(window_id)],
937    };
938    finish_name_call(&state, call, json!({ "success": true, "window_id": window_id })).await
939}
940
941// ---- Auth Middleware ----
942
943/// Auth middleware matching Go pkg/authkey/authkey.go:18-42.
944async fn auth_middleware(
945    State(state): State<AppState>,
946    req: Request<Body>,
947    next: Next,
948) -> Response {
949    if req.method() == Method::OPTIONS {
950        return next.run(req).await;
951    }
952
953    let auth_key = req
954        .headers()
955        .get("X-AuthKey")
956        .and_then(|v| v.to_str().ok())
957        .map(|s| s.to_string());
958
959    // 2026-05-11 audit (C3): the query-string `?authkey=` fallback
960    // bypasses CORS preflight and is preserved in browser history,
961    // navigation `Referer` headers, server access logs, etc. — a CSRF
962    // amplifier whenever the key leaks. It is allowed **only** on the
963    // WebSocket upgrade route (`/ws`), where the browser WS API doesn't
964    // permit custom headers and there is no other practical channel
965    // for the key. Every other route requires the header.
966    let auth_key = auth_key.or_else(|| {
967        if req.uri().path() != "/ws" {
968            return None;
969        }
970        req.uri().query().and_then(|q| {
971            q.split('&')
972                .filter_map(|pair| pair.split_once('='))
973                .find(|(k, _)| *k == "authkey")
974                .map(|(_, v)| v.to_string())
975        })
976    });
977
978    match auth_key {
979        Some(key) if key == state.auth_key => next.run(req).await,
980        _ => (
981            StatusCode::UNAUTHORIZED,
982            Json(json!({"error": "unauthorized"})),
983        )
984            .into_response(),
985    }
986}