agentmux_cef/
ipc.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// IPC bridge between frontend JavaScript and Rust backend.
5//
6// Phase 2: Embedded HTTP server (axum) on localhost with a random port.
7//
8// Architecture:
9//   JS -> Rust:  fetch("http://127.0.0.1:{port}/ipc", { method: "POST", body: JSON.stringify({cmd, args}) })
10//   Rust -> JS:  frame.execute_javascript("window.dispatchEvent(new CustomEvent('agentmux-event', {detail: ...}))")
11//
12// Why HTTP over CEF ProcessMessage:
13//   - cef-rs does not wrap CefMessageRouter (C++ convenience class)
14//   - Building a custom ProcessMessage router requires RenderProcessHandler + V8 bindings
15//   - fetch() is natural for async/await frontend code
16//   - Easy to debug: curl http://127.0.0.1:PORT/ipc -d '{"cmd":"get_platform"}'
17//   - axum is already in the tokio ecosystem
18
19use std::sync::Arc;
20
21use axum::{
22    extract::State,
23    http::{HeaderMap, StatusCode},
24    routing::{get, post},
25    Json, Router,
26};
27use tower_http::cors::CorsLayer;
28use tower_http::services::ServeDir;
29
30use crate::commands;
31use crate::state::AppState;
32
33/// IPC command request from the frontend.
34#[derive(Debug, serde::Deserialize)]
35pub struct IpcRequest {
36    /// Command name (maps to Tauri command names).
37    pub cmd: String,
38    /// Command arguments as JSON.
39    #[serde(default)]
40    pub args: serde_json::Value,
41}
42
43/// IPC response back to the frontend.
44#[derive(Debug, serde::Serialize)]
45pub struct IpcResponse {
46    /// Whether the command succeeded.
47    pub success: bool,
48    /// Result data (on success).
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub data: Option<serde_json::Value>,
51    /// Error message (on failure).
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub error: Option<String>,
54}
55
56/// Health check response.
57#[derive(Debug, serde::Serialize)]
58struct HealthResponse {
59    status: String,
60    version: String,
61}
62
63/// Start the IPC HTTP server on a random localhost port.
64/// Returns the port number.
65pub async fn start_ipc_server(state: Arc<AppState>) -> u16 {
66    // Determine frontend static files directory (next to the executable)
67    let exe_dir = std::env::current_exe()
68        .ok()
69        .and_then(|p| p.parent().map(|d| d.to_path_buf()))
70        .unwrap_or_else(|| std::env::current_dir().unwrap());
71    // Check runtime/frontend/ (portable layout) then frontend/ (dev/flat layout)
72    let runtime_dir = exe_dir.join("runtime");
73    let frontend_dir = if runtime_dir.join("frontend").join("index.html").exists() {
74        runtime_dir.join("frontend")
75    } else {
76        exe_dir.join("frontend")
77    };
78    let has_frontend = frontend_dir.join("index.html").exists();
79    if has_frontend {
80        tracing::info!("Serving static frontend from: {}", frontend_dir.display());
81    }
82
83    let mut app = Router::new()
84        .route("/ipc", post(handle_ipc))
85        .route("/health", get(health));
86    // Browser DOM API routes (`/agentmux/browser/*`). Token auth is
87    // enforced inside each handler — same bearer scheme as /ipc.
88    app = crate::browser_api::register_routes(app);
89    let mut app = app
90        .layer(CorsLayer::permissive())
91        .with_state(state);
92
93    // Serve built frontend as static files (for portable/production builds)
94    if has_frontend {
95        app = app.fallback_service(ServeDir::new(&frontend_dir));
96    }
97
98    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
99        .await
100        .expect("Failed to bind IPC server");
101    let port = listener
102        .local_addr()
103        .expect("Failed to get local address")
104        .port();
105
106    tracing::info!("IPC HTTP server started on 127.0.0.1:{}", port);
107
108    tokio::spawn(async move {
109        axum::serve(listener, app)
110            .await
111            .expect("IPC server error");
112    });
113
114    port
115}
116
117/// Health check endpoint.
118async fn health() -> Json<HealthResponse> {
119    Json(HealthResponse {
120        status: "ok".to_string(),
121        version: env!("CARGO_PKG_VERSION").to_string(),
122    })
123}
124
125/// Main IPC handler — routes commands to the appropriate handler.
126///
127/// Requires `Authorization: Bearer {ipc_token}` header to prevent
128/// unauthorized local processes from accessing the IPC server.
129async fn handle_ipc(
130    State(state): State<Arc<AppState>>,
131    headers: HeaderMap,
132    Json(req): Json<IpcRequest>,
133) -> (StatusCode, Json<IpcResponse>) {
134    // Verify IPC token
135    let authorized = headers
136        .get("authorization")
137        .and_then(|v| v.to_str().ok())
138        .and_then(|v| v.strip_prefix("Bearer "))
139        .map(|token| token == state.ipc_token)
140        .unwrap_or(false);
141
142    if !authorized {
143        return (
144            StatusCode::UNAUTHORIZED,
145            Json(IpcResponse {
146                success: false,
147                data: None,
148                error: Some("Unauthorized: invalid or missing IPC token".to_string()),
149            }),
150        );
151    }
152
153    tracing::debug!("IPC request: cmd={} args={}", req.cmd, req.args);
154
155    let result = route_command(&state, &req.cmd, &req.args).await;
156
157    match result {
158        Ok(data) => (
159            StatusCode::OK,
160            Json(IpcResponse {
161                success: true,
162                data: Some(data),
163                error: None,
164            }),
165        ),
166        Err(error) => (
167            StatusCode::OK, // Return 200 even on errors — frontend checks success field
168            Json(IpcResponse {
169                success: false,
170                data: None,
171                error: Some(error),
172            }),
173        ),
174    }
175}
176
177/// Route a command to the appropriate handler.
178///
179/// Command names use snake_case to match the Tauri command names.
180/// The frontend sends these exact names via invokeCommand().
181async fn route_command(
182    state: &Arc<AppState>,
183    cmd: &str,
184    args: &serde_json::Value,
185) -> Result<serde_json::Value, String> {
186    // Check stubs first
187    if commands::stubs::is_stub_command(cmd) {
188        return Ok(commands::stubs::handle_stub(cmd, args));
189    }
190
191    match cmd {
192        // ---- Tier 1: Bootstrap (must work for frontend to load) ----
193        "get_platform" => Ok(commands::platform::get_platform()),
194        "get_auth_key" => {
195            let key = state.auth_key.lock().clone();
196            tracing::debug!("Frontend requested auth key: {}...", &key[..8.min(key.len())]);
197            Ok(serde_json::json!(key))
198        }
199        "get_is_dev" => Ok(commands::platform::get_is_dev()),
200        "get_user_name" => Ok(commands::platform::get_user_name()),
201        "get_host_name" => Ok(commands::platform::get_host_name()),
202        "get_data_dir" => commands::platform::get_data_dir(state),
203        "get_config_dir" => commands::platform::get_config_dir(state),
204        "get_user_home_dir" => commands::platform::get_user_home_dir(state),
205        "get_docsite_url" => Ok(commands::platform::get_docsite_url(state)),
206        "get_zoom_factor" => Ok(commands::window::get_zoom_factor(state)),
207        "get_about_modal_details" => Ok(commands::platform::get_about_modal_details(state)),
208        "get_host_info" => Ok(commands::platform::get_host_info(state)),
209        "get_backend_endpoints" => commands::backend::get_backend_endpoints(state),
210        "get_wave_init_opts" => commands::backend::get_wave_init_opts(state),
211        "set_window_init_status" => Ok(commands::backend::set_window_init_status(state, args)),
212        "fe_log" => Ok(commands::backend::fe_log(args)),
213        "fe_log_structured" => Ok(commands::backend::fe_log_structured(args)),
214
215        // ---- Tier 2: Core functionality ----
216        "get_backend_info" => Ok(commands::backend::get_backend_info(state)),
217        "restart_backend" => commands::backend::restart_backend(state.clone()).await,
218        "close_window" => commands::window::close_window(state, args),
219        "minimize_window" => commands::window::minimize_window(state, args),
220        "maximize_window" => commands::window::maximize_window(state, args),
221        "set_zoom_factor" => commands::window::set_zoom_factor(state, args),
222        "is_main_window" => Ok(commands::window::is_main_window(args)),
223        "get_window_label" => Ok(commands::window::get_window_label(args)),
224        "open_new_window" => commands::window::open_new_window(state),
225        "open_subwindow" => {
226            // Agent / backend-only API — creates a sub-window tied to a full
227            // instance. Hidden from the taskbar. Not exposed in user UI.
228            let parent = args
229                .get("parent_instance_id")
230                .and_then(|v| v.as_str())
231                .ok_or_else(|| "open_subwindow: parent_instance_id required".to_string())?
232                .to_string();
233            commands::window::open_subwindow(state, parent)
234        }
235        "open_floating_pane_window" => {
236            // Phase 1 of floating-pane tear-off (issue #810 / spec
237            // SPEC_FLOATING_PANE_TEAROFF_2026_05_11.md). Creates a
238            // subordinate `WS_POPUP + WS_EX_TOOLWINDOW` HWND owned by
239            // the source main window, with a CEF browser embedded.
240            // Windows-only; macOS / Linux return a clear error.
241            commands::floating_pane::open_floating_pane_window(state, args)
242        }
243        "get_instance_number" => Ok(commands::window::get_instance_number(state, args)),
244        "register_backend_window" => Ok(commands::window::register_backend_window(state, args)),
245        "get_env" => Ok(commands::platform::get_env(args)),
246        "open_external" => commands::platform::open_external(args),
247        "set_window_transparency" => commands::window::set_window_transparency(state, args),
248        "set_window_opacity" => commands::window::set_window_opacity(state, args),
249        "get_window_opacity" => commands::window::get_window_opacity(state, args),
250        "start_window_drag" => commands::window::start_window_drag(state, args),
251        "get_window_position" => commands::window::get_window_position(state),
252        "move_window_by" => commands::window::move_window_by(state, args),
253        "set_window_position" => commands::window::set_window_position(state, args),
254        "toggle_devtools" => commands::window::toggle_devtools(state, args),
255        "show_context_menu" => {
256            tracing::debug!("show_context_menu: handled in JS overlay");
257            Ok(serde_json::Value::Null)
258        }
259
260        // ---- Cross-window drag ----
261        "start_cross_drag" => commands::drag::start_cross_drag(state, args),
262        "update_cross_drag" => commands::drag::update_cross_drag(state, args),
263        "complete_cross_drag" => commands::drag::complete_cross_drag(state, args),
264        "cancel_cross_drag" => commands::drag::cancel_cross_drag(state, args),
265        "get_cursor_point" => commands::drag::get_cursor_point(),
266        "get_mouse_button_state" => commands::drag::get_mouse_button_state(),
267        "set_drag_cursor" => commands::drag::set_drag_cursor(),
268        "restore_drag_cursor" => commands::drag::restore_drag_cursor(),
269        "release_drag_capture" => commands::drag::release_drag_capture(state),
270        "set_js_drag_active" => commands::drag::set_js_drag_active(args),
271        "open_window_at_position" => commands::drag::open_window_at_position(state, args),
272        "tear_off_pool_promote" => commands::drag::tear_off_pool_promote(state, args),
273        "pool_window_ready" => commands::drag::pool_window_ready(state, args),
274        "tear_off_sc_move_handshake" => {
275            // Wrap in spawn_blocking — the handler polls state.browsers
276            // for up to 2s waiting for the destination window's HWND to
277            // register (cold path, gone after Phase 6 warm pool). Without
278            // this wrap it would block a Tokio worker.
279            let state_clone = state.clone();
280            let args_clone = args.clone();
281            tokio::task::spawn_blocking(move || {
282                commands::drag::tear_off_sc_move_handshake(&state_clone, &args_clone)
283            })
284            .await
285            .map_err(|e| format!("tear_off_sc_move_handshake join error: {}", e))?
286        }
287        "list_windows" => Ok(commands::window::list_windows(state)),
288        "list_window_instances" => Ok(commands::window::list_window_instances(state)),
289        "get_double_click_time" => Ok(commands::window::get_double_click_time()),
290        "focus_window" => commands::window::focus_window(state, args),
291        "close_window_by_label" => commands::window::close_window_by_label(state, args),
292
293        // ---- Clipboard (CEF can't use navigator.clipboard without permission policy) ----
294        "read_clipboard" => commands::clipboard::read_clipboard(),
295        "write_clipboard" => commands::clipboard::write_clipboard(args),
296
297        // ---- Tier 3: Provider/CLI management ----
298        "detect_installed_clis" => commands::providers::detect_installed_clis().await,
299        "get_provider_config" => commands::providers::get_provider_config(state),
300        "save_provider_config" => commands::providers::save_provider_config(state, args),
301        "get_provider_install_info" => commands::providers::get_provider_install_info(args),
302        "set_provider_auth" => commands::providers::set_provider_auth(state, args).await,
303        "clear_provider_auth" => commands::providers::clear_provider_auth(state, args),
304        "get_provider_auth_status" => commands::providers::get_provider_auth_status(state, args),
305        "check_cli_auth_status" => commands::providers::check_cli_auth_status(args).await,
306        "install_cli" => commands::providers::install_cli(state, args).await,
307        "get_cli_path" => commands::providers::get_cli_path(state, args),
308        "check_nodejs_available" => commands::providers::check_nodejs_available().await,
309        "ensure_auth_dir" => commands::platform::ensure_auth_dir(state, args),
310        "run_cli_login" => commands::platform::run_cli_login(state.clone(), args).await,
311        "cancel_cli_login" => commands::platform::cancel_cli_login(state),
312        "ensure_settings_file" => commands::platform::ensure_settings_file(state),
313        "open_in_editor" => commands::platform::open_in_editor(args),
314        "copy_file_to_dir" => commands::providers::copy_file_to_dir(args),
315
316        // ---- Command palette ----
317        "run_command" => commands::palette::run_command(state, args),
318
319        // ---- App API (frontend-driven) ----
320        "open_agent" => commands::palette::open_agent(state, args),
321
322        // ---- Browser panes (native CefBrowserView) ----
323        "browser_pane_create" => {
324            let block_id = args.get("block_id").and_then(|v| v.as_str()).unwrap_or("");
325            let url = args.get("url").and_then(|v| v.as_str()).unwrap_or("about:blank");
326            let x = args.get("x").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
327            let y = args.get("y").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
328            let w = args.get("width").and_then(|v| v.as_i64()).unwrap_or(800) as i32;
329            let h = args.get("height").and_then(|v| v.as_i64()).unwrap_or(600) as i32;
330            let rect = cef::Rect { x, y, width: w, height: h };
331            // window_label: which window the pane should be attached to
332            // (Linux/macOS Views path looks this up in state.windows). Default
333            // to "main" for backward compat with frontends that don't send it.
334            // Windows path ignores it (find_own_top_level_window resolves the
335            // calling window's HWND directly).
336            let window_label = args
337                .get("window_label")
338                .and_then(|v| v.as_str())
339                .unwrap_or("main");
340            state.browser_panes.create(state, block_id, url, rect, window_label)?;
341            Ok(serde_json::json!(true))
342        }
343        "browser_pane_navigate" => {
344            let block_id = args.get("block_id").and_then(|v| v.as_str()).unwrap_or("");
345            let url = args.get("url").and_then(|v| v.as_str()).unwrap_or("");
346            state.browser_panes.navigate(block_id, url, state)?;
347            Ok(serde_json::json!(true))
348        }
349        "browser_pane_resize" => {
350            let block_id = args.get("block_id").and_then(|v| v.as_str()).unwrap_or("");
351            let x = args.get("x").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
352            let y = args.get("y").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
353            let w = args.get("width").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
354            let h = args.get("height").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
355            // debug! not info! — fires on every pixel during a window-resize
356            // drag (the lastSentRect gate skips no-op calls but a real drag
357            // emits many genuine rect changes). Reagent P2 on PR #788.
358            tracing::debug!(
359                "[ipc] browser_pane_resize block_id={} rect=({},{},{},{})",
360                block_id, x, y, w, h
361            );
362            state.browser_panes.resize(block_id, cef::Rect { x, y, width: w, height: h }, state);
363            Ok(serde_json::json!(true))
364        }
365        "browser_pane_close" => {
366            let block_id = args.get("block_id").and_then(|v| v.as_str()).unwrap_or("");
367            // Cancel any pending HTTP-auth callbacks parked for this
368            // pane before tearing it down. Without this, closing a
369            // pane mid-auth-prompt leaks the CEF AuthCallback refcount
370            // until the 5-minute TTL fires.
371            crate::browser_pane::auth::cancel_for_block(block_id);
372            state.browser_panes.close(block_id, state);
373            Ok(serde_json::json!(true))
374        }
375        "browser_pane_go_back" => {
376            let block_id = args.get("block_id").and_then(|v| v.as_str()).unwrap_or("");
377            state.browser_panes.go_back(block_id, state);
378            Ok(serde_json::json!(true))
379        }
380        "browser_pane_go_forward" => {
381            let block_id = args.get("block_id").and_then(|v| v.as_str()).unwrap_or("");
382            state.browser_panes.go_forward(block_id, state);
383            Ok(serde_json::json!(true))
384        }
385        "browser_pane_reload" => {
386            let block_id = args.get("block_id").and_then(|v| v.as_str()).unwrap_or("");
387            state.browser_panes.reload(block_id, state);
388            Ok(serde_json::json!(true))
389        }
390        "browser_pane_focus" => {
391            let block_id = args.get("block_id").and_then(|v| v.as_str()).unwrap_or("");
392            tracing::info!("[ipc] browser_pane_focus block_id={}", block_id);
393            state.browser_panes.focus(block_id, state);
394            Ok(serde_json::json!(true))
395        }
396        "browser_pane_auth_submit" => {
397            // Renderer collected credentials from the modal. Resolve the
398            // CEF AuthCallback parked under request_id and continue.
399            // Phase α of SPEC_BROWSER_PANE_HTTP_BASIC_AUTH_2026_05_18.md.
400            let request_id = args.get("request_id").and_then(|v| v.as_str()).unwrap_or("");
401            let username = args.get("username").and_then(|v| v.as_str()).unwrap_or("");
402            let password = args.get("password").and_then(|v| v.as_str()).unwrap_or("");
403            // Don't log username/password length — host logs are
404            // retained 7 days per CLAUDE.md and the length is sensitive
405            // metadata that could narrow a brute-force window.
406            // request_id alone is enough to trace flow.
407            tracing::info!(
408                "[browser-pane-auth] submit request_id={}",
409                request_id,
410            );
411            if let Some(cb) = crate::browser_pane::auth::take(request_id) {
412                use cef::ImplAuthCallback;
413                let u = cef::CefString::from(username);
414                let p = cef::CefString::from(password);
415                cb.cont(Some(&u), Some(&p));
416                Ok(serde_json::json!(true))
417            } else {
418                tracing::warn!(
419                    "[browser-pane-auth] submit for unknown request_id {} — already resolved?",
420                    request_id
421                );
422                Ok(serde_json::json!(false))
423            }
424        }
425        "browser_pane_auth_cancel" => {
426            let request_id = args.get("request_id").and_then(|v| v.as_str()).unwrap_or("");
427            tracing::info!("[browser-pane-auth] cancel request_id={}", request_id);
428            if let Some(cb) = crate::browser_pane::auth::take(request_id) {
429                use cef::ImplAuthCallback;
430                cb.cancel();
431                Ok(serde_json::json!(true))
432            } else {
433                Ok(serde_json::json!(false))
434            }
435        }
436        "browser_panes_set_overlay_clip" => {
437            // Apply a clip region to every pane HWND that excludes the given
438            // overlay rectangles. The pane stays visible everywhere except
439            // under the overlays — DOM overlays render through the holes.
440            // Empty list restores full visibility. See
441            // BROWSER_PANE_Z_ORDER_FOCUS_REPORT.md Issue 1.
442            //
443            // `window_label` scopes the clip to panes owned by the
444            // requesting window so a modal in window B doesn't also
445            // hide panes in window A (Codex P1 on PR #544). Defaults to
446            // "main" for back-compat with older frontends that omit it.
447            //
448            // Each rect: { x, y, w, h } in main-window client pixel coords.
449            let window_label = args
450                .get("window_label")
451                .and_then(|v| v.as_str())
452                .unwrap_or("main")
453                .to_string();
454            let rects: Vec<(i32, i32, i32, i32)> = args
455                .get("rects")
456                .and_then(|v| v.as_array())
457                .map(|arr| {
458                    arr.iter()
459                        .filter_map(|item| {
460                            let x = item.get("x")?.as_i64()? as i32;
461                            let y = item.get("y")?.as_i64()? as i32;
462                            let w = item.get("w")?.as_i64()? as i32;
463                            let h = item.get("h")?.as_i64()? as i32;
464                            Some((x, y, w, h))
465                        })
466                        .collect()
467                })
468                .unwrap_or_default();
469            state
470                .browser_panes
471                .set_pane_overlay_clip(state, &window_label, &rects);
472            Ok(serde_json::json!(true))
473        }
474        "main_window_focus" => {
475            // Move keyboard focus back to the main browser when the user
476            // clicks a main-DOM input (address bar, etc). Previously this
477            // called SetFocus(top_level) where top_level was the outer CEF
478            // Views window — which does NOT route keyboard to the embedded
479            // render widget. Keys kept arriving at the pane's HWND.
480            //
481            // Correct path: tell Chromium that main's Browser has focus.
482            // CEF internally calls SetFocus on the right Chrome_RenderWidgetHostHWND.
483            // Also defocus every pane browser so Chromium stops routing input
484            // to them.
485            //
486            // `window_label` arg: routes focus to THE window that sent the
487            // IPC. Without it, we'd iterate state.browsers and take the
488            // first non-pane entry (always `label=main`), so clicking an
489            // input in window 2 would reclaim focus to window 1. Default
490            // "main" for back-compat if an older frontend omits the arg.
491            let window_label = args
492                .get("window_label")
493                .and_then(|v| v.as_str())
494                .unwrap_or("main")
495                .to_string();
496            tracing::info!("[ipc] main_window_focus window_label={}", window_label);
497
498            // Phase H.2.b — reducer-aware lookup with fallback. Try the
499            // requested label first; on miss, pick any non-pane browser
500            // (covers the corner case where the requested window label
501            // isn't registered yet — race on first DOM focus before
502            // registerBackendWindow lands).
503            let target_browser = state
504                .get_browser(&window_label)
505                .map(|b| (window_label.clone(), b))
506                .or_else(|| {
507                    state
508                        .list_browsers()
509                        .into_iter()
510                        .find(|(k, _)| !k.starts_with("browser-pane-"))
511                });
512
513            if let Some((label, _browser)) = target_browser {
514                // Full focus reclaim has to run on the CEF UI thread:
515                // `browser_view_get_for_browser`, `host.set_focus`, and
516                // walking the HWND tree all require it. The task also
517                // handles `defocus_all` on panes.
518                let mut task = crate::ui_tasks::MainFocusReclaimTask::new(
519                    state.clone(),
520                    label.clone(),
521                );
522                cef::post_task(cef::ThreadId::UI, Some(&mut task));
523                tracing::info!("[ipc] main_window_focus: posted MainFocusReclaimTask for label={}", label);
524            } else {
525                tracing::warn!("[ipc] main_window_focus: no browser found for label={}", window_label);
526            }
527
528            Ok(serde_json::json!(true))
529        }
530
531        // ---- Unknown command ----
532        _ => Err(format!("Unknown command: {}", cmd)),
533    }
534}
535