Skip to main content

victauri_plugin/mcp/
mod.rs

1// This file is intentionally large (~3,400 lines). rmcp's `#[tool_router]`
2// macro requires every `#[tool]` method to live in a single `impl` block, so
3// splitting the handler across files would break tool registration. Parameter
4// structs are already factored into sub-modules (webview_params, window_params,
5// etc.) to keep this file focused on dispatch logic.
6
7mod backend_params;
8mod compound_params;
9mod helpers;
10mod introspection_params;
11mod other_params;
12mod rest;
13mod server;
14mod verification_params;
15mod webview_params;
16mod window_params;
17
18use std::collections::{HashMap, HashSet};
19use std::sync::Arc;
20use std::sync::atomic::{AtomicBool, Ordering};
21
22use rmcp::handler::server::tool::ToolCallContext;
23use rmcp::handler::server::wrapper::Parameters;
24use rmcp::model::{
25    AnnotateAble, CallToolRequestParams, CallToolResult, Content, ListResourcesResult,
26    ListToolsResult, PaginatedRequestParams, RawContent, RawResource, ReadResourceRequestParams,
27    ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, SubscribeRequestParams,
28    Tool, UnsubscribeRequestParams,
29};
30use rmcp::service::RequestContext;
31use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_router};
32use tokio::sync::Mutex;
33
34use crate::VictauriState;
35use crate::bridge::WebviewBridge;
36
37use helpers::{
38    RecoveryHint, js_string, json_result, missing_param, sanitize_css_color, tool_disabled,
39    tool_error, tool_error_with_hint, validate_url,
40};
41
42pub use backend_params::*;
43pub use compound_params::*;
44pub use introspection_params::*;
45pub use other_params::{
46    DiagnosticsParams, FindElementsParams, ResolveCommandParams, SemanticAssertParams,
47    WaitCondition, WaitForParams,
48};
49pub use server::*;
50pub use verification_params::*;
51pub use webview_params::*;
52pub use window_params::*;
53
54// ── MCP Handler ──────────────────────────────────────────────────────────────
55
56/// Maximum number of in-flight JavaScript eval requests. Prevents unbounded
57/// growth of the `pending_evals` map if callbacks are never resolved.
58pub(crate) const MAX_PENDING_EVALS: usize = 100;
59
60fn chrono_now() -> String {
61    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
62}
63
64/// Maximum length of JavaScript code accepted by the `eval_js` tool (1 MB).
65const MAX_EVAL_CODE_LEN: usize = 1_000_000;
66
67/// Maximum length of a JavaScript eval return value (5 MB).
68/// Results exceeding this are truncated to prevent memory exhaustion.
69const MAX_EVAL_RESULT_LEN: usize = 5_000_000;
70
71/// Default number of entries returned by IPC/network log tools when no explicit
72/// `limit` is given. Prevents busy apps (large logs) from exceeding the eval cap.
73const DEFAULT_LOG_LIMIT: usize = 100;
74
75/// Per-field byte cap applied to each IPC/network log entry before serialization.
76/// Large request/response bodies are truncated with a marker so the aggregate
77/// log stays well under [`MAX_EVAL_RESULT_LEN`] even on heavy-traffic apps.
78const MAX_LOG_FIELD_BYTES: usize = 4096;
79
80const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
81const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
82const RESOURCE_URI_STATE: &str = "victauri://state";
83
84const BRIDGE_VERSION: &str = "0.5.0";
85
86const SAFE_ENV_PREFIXES: &[&str] = &[
87    "HOME",
88    "USER",
89    "LANG",
90    "LC_",
91    "TERM",
92    "SHELL",
93    "DISPLAY",
94    "XDG_",
95    "TAURI_",
96    "VICTAURI_",
97    "NODE_ENV",
98    "OS",
99    "HOSTNAME",
100    "PWD",
101    "SHLVL",
102    "LOGNAME",
103];
104
105/// MCP tool handler that dispatches tool calls to the webview bridge and state.
106#[derive(Clone)]
107pub struct VictauriMcpHandler {
108    state: Arc<VictauriState>,
109    bridge: Arc<dyn WebviewBridge>,
110    subscriptions: Arc<Mutex<HashSet<String>>>,
111    bridge_checked: Arc<AtomicBool>,
112    probed_labels: Arc<Mutex<HashSet<String>>>,
113    /// Window keys whose previous eval timed out. The next eval on such a window
114    /// does a fast liveness probe so a reloaded/crashed bridge fails fast with a
115    /// clear error instead of blocking the full timeout again.
116    timed_out_labels: Arc<Mutex<HashSet<String>>>,
117}
118
119#[tool_router]
120impl VictauriMcpHandler {
121    // ── Standalone Tools ────────────────────────────────────────────────────
122
123    #[tool(
124        description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.",
125        annotations(
126            read_only_hint = false,
127            destructive_hint = true,
128            idempotent_hint = false,
129            open_world_hint = false
130        )
131    )]
132    async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
133        if !self.state.privacy.is_tool_enabled("eval_js") {
134            return tool_disabled("eval_js");
135        }
136        if params.code.len() > MAX_EVAL_CODE_LEN {
137            return tool_error("code exceeds maximum length (1 MB)");
138        }
139        match self
140            .eval_with_return(&params.code, params.webview_label.as_deref())
141            .await
142        {
143            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
144            Err(e) => tool_error(e),
145        }
146    }
147
148    #[tool(
149        description = "Get the DOM snapshot with stable ref handles. Default: compact accessible text (70-80%% fewer tokens). Set format=\"json\" for full tree. Returns tree + stale_refs (refs invalidated since last snapshot).",
150        annotations(
151            read_only_hint = true,
152            destructive_hint = false,
153            idempotent_hint = true,
154            open_world_hint = false
155        )
156    )]
157    async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
158        let format = params.format.unwrap_or(SnapshotFormat::Compact);
159        let format_str = match format {
160            SnapshotFormat::Compact => "compact",
161            SnapshotFormat::Json => "json",
162        };
163        let code = format!(
164            "return window.__VICTAURI__?.snapshot({})",
165            js_string(format_str)
166        );
167        self.eval_bridge(&code, params.webview_label.as_deref())
168            .await
169    }
170
171    #[tool(
172        description = "Search for elements by text, role, test_id, CSS selector (via `css` or `selector` param), or accessible name without a full snapshot. Returns lightweight matches with ref handles.",
173        annotations(
174            read_only_hint = true,
175            destructive_hint = false,
176            idempotent_hint = true,
177            open_world_hint = false
178        )
179    )]
180    async fn find_elements(
181        &self,
182        Parameters(params): Parameters<FindElementsParams>,
183    ) -> CallToolResult {
184        let mut parts: Vec<String> = Vec::new();
185        if let Some(t) = &params.text {
186            parts.push(format!("text: {}", js_string(t)));
187        }
188        if let Some(r) = &params.role {
189            parts.push(format!("role: {}", js_string(r)));
190        }
191        if let Some(tid) = &params.test_id {
192            parts.push(format!("test_id: {}", js_string(tid)));
193        }
194        if let Some(c) = params.css.as_ref().or(params.selector.as_ref()) {
195            parts.push(format!("css: {}", js_string(c)));
196        }
197        if let Some(n) = &params.name {
198            parts.push(format!("name: {}", js_string(n)));
199        }
200        if let Some(max) = params.max_results {
201            parts.push(format!("max_results: {max}"));
202        }
203        if let Some(t) = &params.tag {
204            parts.push(format!("tag: {}", js_string(t)));
205        }
206        if let Some(p) = &params.placeholder {
207            parts.push(format!("placeholder: {}", js_string(p)));
208        }
209        if let Some(a) = &params.alt {
210            parts.push(format!("alt: {}", js_string(a)));
211        }
212        if let Some(ta) = &params.title_attr {
213            parts.push(format!("title_attr: {}", js_string(ta)));
214        }
215        if let Some(l) = &params.label {
216            parts.push(format!("label: {}", js_string(l)));
217        }
218        if let Some(true) = params.exact {
219            parts.push("exact: true".to_string());
220        }
221        if let Some(e) = params.enabled {
222            parts.push(format!("enabled: {e}"));
223        }
224        let code = format!(
225            "return window.__VICTAURI__?.findElements({{ {} }})",
226            parts.join(", ")
227        );
228        match self
229            .eval_with_return(&code, params.webview_label.as_deref())
230            .await
231        {
232            Ok(result) => {
233                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
234                    && let Some(err) = parsed.get("error").and_then(|e| e.as_str())
235                {
236                    return tool_error(err);
237                }
238                CallToolResult::success(vec![Content::text(result)])
239            }
240            Err(e) => tool_error(e),
241        }
242    }
243
244    #[tool(
245        description = "Invoke a registered Tauri command via IPC, just like the frontend would. Goes through the real IPC pipeline so calls are logged and verifiable. Returns the command's result. Subject to privacy command filtering.",
246        annotations(
247            read_only_hint = false,
248            destructive_hint = true,
249            idempotent_hint = false,
250            open_world_hint = false
251        )
252    )]
253    async fn invoke_command(
254        &self,
255        Parameters(params): Parameters<InvokeCommandParams>,
256    ) -> CallToolResult {
257        if !self.state.privacy.is_invoke_allowed(&params.command) {
258            return tool_disabled("invoke_command");
259        }
260        if !self.state.privacy.is_command_allowed(&params.command) {
261            return tool_error(format!(
262                "command '{}' is blocked by privacy configuration",
263                params.command
264            ));
265        }
266
267        // ── Fault injection check ──
268        if let Some(fault) = self.state.fault_registry.check_and_trigger(&params.command) {
269            match fault {
270                crate::introspection::FaultType::Delay { delay_ms } => {
271                    tracing::info!(
272                        command = %params.command,
273                        delay_ms = delay_ms,
274                        "fault injection: delaying command"
275                    );
276                    tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
277                    // After delay, continue with normal execution below
278                }
279                crate::introspection::FaultType::Error { ref message } => {
280                    tracing::info!(
281                        command = %params.command,
282                        "fault injection: returning error"
283                    );
284                    return tool_error(format!(
285                        "[FAULT INJECTED] command '{}': {message}",
286                        params.command
287                    ));
288                }
289                crate::introspection::FaultType::Drop => {
290                    tracing::info!(
291                        command = %params.command,
292                        "fault injection: dropping response"
293                    );
294                    return CallToolResult::success(vec![Content::text("{}")]);
295                }
296                crate::introspection::FaultType::Corrupt => {
297                    tracing::info!(
298                        command = %params.command,
299                        "fault injection: corrupting response"
300                    );
301                    // Execute normally but mangle the response
302                    let args_json = params.args.unwrap_or(serde_json::json!({}));
303                    let args_str =
304                        serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
305                    let code = format!(
306                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
307                        js_string(&params.command)
308                    );
309                    if let Ok(result) = self
310                        .eval_with_return(&code, params.webview_label.as_deref())
311                        .await
312                    {
313                        let corrupted = format!(
314                            "{{\"__corrupted\":true,\"original_length\":{},\"fault\":\"corrupt\"}}",
315                            result.len()
316                        );
317                        return CallToolResult::success(vec![Content::text(corrupted)]);
318                    }
319                    return CallToolResult::success(vec![Content::text(
320                        "{\"__corrupted\":true,\"fault\":\"corrupt\",\"note\":\"original invocation also failed\"}",
321                    )]);
322                }
323            }
324        }
325
326        // ── Normal execution with timing ──
327        let start = std::time::Instant::now();
328        let args_json = params.args.unwrap_or(serde_json::json!({}));
329        let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
330        let code = format!(
331            "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
332            js_string(&params.command)
333        );
334        let result = self
335            .eval_with_return(&code, params.webview_label.as_deref())
336            .await;
337        let elapsed = start.elapsed();
338        self.state.command_timings.record(&params.command, elapsed);
339
340        match result {
341            Ok(result) => {
342                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
343                    && let Some(err) = parsed.get("__error").and_then(|e| e.as_str())
344                {
345                    return tool_error(format!(
346                        "command '{}' returned error: {err}",
347                        params.command
348                    ));
349                }
350                CallToolResult::success(vec![Content::text(result)])
351            }
352            Err(e) => tool_error(format!("invoke_command failed: {e}")),
353        }
354    }
355
356    #[tool(
357        description = "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Works on Windows (PrintWindow), macOS (CGWindowListCreateImage), and Linux (X11/Wayland).",
358        annotations(
359            read_only_hint = true,
360            destructive_hint = false,
361            idempotent_hint = true,
362            open_world_hint = false
363        )
364    )]
365    async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
366        self.track_tool_call();
367        if !self.state.privacy.is_tool_enabled("screenshot") {
368            return tool_disabled("screenshot");
369        }
370        match self
371            .bridge
372            .get_native_handle(params.window_label.as_deref())
373        {
374            Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
375                Ok(png_bytes) => {
376                    use base64::Engine;
377                    let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
378                    CallToolResult::success(vec![Content::image(b64, "image/png")])
379                }
380                Err(e) => tool_error(format!("screenshot capture failed: {e}")),
381            },
382            Err(e) => tool_error(format!("cannot get window handle: {e}")),
383        }
384    }
385
386    #[tool(
387        description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.",
388        annotations(
389            read_only_hint = true,
390            destructive_hint = false,
391            idempotent_hint = true,
392            open_world_hint = false
393        )
394    )]
395    async fn verify_state(
396        &self,
397        Parameters(params): Parameters<VerifyStateParams>,
398    ) -> CallToolResult {
399        if !self.state.privacy.is_tool_enabled("eval_js") {
400            return tool_disabled("verify_state requires eval_js capability");
401        }
402        let code = format!("return ({})", params.frontend_expr);
403        let frontend_json = match self
404            .eval_with_return(&code, params.webview_label.as_deref())
405            .await
406        {
407            Ok(result) => result,
408            Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
409        };
410
411        let frontend_state: serde_json::Value = match serde_json::from_str(&frontend_json) {
412            Ok(v) => v,
413            Err(e) => {
414                return tool_error(format!(
415                    "frontend expression did not return valid JSON: {e}"
416                ));
417            }
418        };
419
420        let backend_state = if let Some(state) = params.backend_state {
421            state
422        } else if let Some(ref cmd) = params.backend_command {
423            if !self.state.privacy.is_command_allowed(cmd) {
424                return tool_error(format!(
425                    "command '{cmd}' is blocked by privacy configuration"
426                ));
427            }
428            let args = params.backend_args.unwrap_or(serde_json::json!({}));
429            let args_str = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
430            let invoke_code = format!(
431                "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
432                js_string(cmd)
433            );
434            match self
435                .eval_with_return(&invoke_code, params.webview_label.as_deref())
436                .await
437            {
438                Ok(result) => match serde_json::from_str(&result) {
439                    Ok(v) => v,
440                    Err(e) => {
441                        return tool_error(format!(
442                            "backend command '{cmd}' did not return valid JSON: {e}"
443                        ));
444                    }
445                },
446                Err(e) => {
447                    return tool_error(format!("failed to invoke backend command '{cmd}': {e}"));
448                }
449            }
450        } else {
451            return tool_error("either backend_state or backend_command must be provided");
452        };
453
454        let result = victauri_core::verify_state(frontend_state, backend_state);
455        json_result(&result)
456    }
457
458    #[tool(
459        description = "Detect ghost commands — commands invoked from the frontend that have no backend handler, or registered backend commands never called. Reads the JS-side IPC interception log, which ACCUMULATES every command seen this session (including earlier test/probe traffic) — so a 'FrontendOnly' result reflects what was invoked at runtime, not necessarily a real frontend bug. For a clean signal, call `logs {action:'clear'}` first, then exercise the app, then run this.",
460        annotations(
461            read_only_hint = true,
462            destructive_hint = false,
463            idempotent_hint = true,
464            open_world_hint = false
465        )
466    )]
467    async fn detect_ghost_commands(
468        &self,
469        Parameters(params): Parameters<GhostCommandParams>,
470    ) -> CallToolResult {
471        // Project only command names in JS — ghost detection never needs request
472        // or response bodies, so this stays tiny even when the full IPC log is
473        // huge (avoids the eval size cap on busy apps).
474        let code = "return (window.__VICTAURI__?.getIpcLog() || []).map(function(c){ return (c && c.command) || null; }).filter(function(x){ return x; })";
475        let ipc_json = match self
476            .eval_with_return(code, params.webview_label.as_deref())
477            .await
478        {
479            Ok(r) => r,
480            Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
481        };
482
483        let command_names: Vec<String> = match serde_json::from_str(&ipc_json) {
484            Ok(v) => v,
485            Err(e) => return tool_error(format!("failed to parse IPC log JSON: {e}")),
486        };
487        let frontend_commands: Vec<String> = command_names
488            .into_iter()
489            .collect::<std::collections::HashSet<_>>()
490            .into_iter()
491            .collect();
492
493        let report = victauri_core::detect_ghost_commands(&frontend_commands, &self.state.registry);
494        json_result(&report)
495    }
496
497    #[tool(
498        description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.",
499        annotations(
500            read_only_hint = true,
501            destructive_hint = false,
502            idempotent_hint = true,
503            open_world_hint = false
504        )
505    )]
506    async fn check_ipc_integrity(
507        &self,
508        Parameters(params): Parameters<IpcIntegrityParams>,
509    ) -> CallToolResult {
510        let threshold = params.stale_threshold_ms.unwrap_or(5000);
511        let code = format!(
512            r"return (function() {{
513                var log = window.__VICTAURI__?.getIpcLog() || [];
514                var now = Date.now();
515                var threshold = {threshold};
516                var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
517                var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
518                var errored = log.filter(function(c) {{ return c.status === 'error'; }});
519                var net = window.__VICTAURI__?.getNetworkLog() || [];
520                var warning = null;
521                if (log.length === 0 && net.length > 5) {{
522                    warning = 'Zero IPC calls captured but ' + net.length + ' network requests observed. IPC capture may not be working — verify the app uses Tauri IPC via fetch to ipc.localhost.';
523                }}
524                return {{
525                    healthy: stale.length === 0 && errored.length === 0,
526                    total_calls: log.length,
527                    pending_count: pending.length,
528                    stale_count: stale.length,
529                    error_count: errored.length,
530                    stale_calls: stale.slice(0, 20),
531                    errored_calls: errored.slice(0, 20),
532                    warning: warning
533                }};
534            }})()"
535        );
536        self.eval_bridge(&code, params.webview_label.as_deref())
537            .await
538    }
539
540    #[tool(
541        description = "Wait for a condition to be met. Polls at regular intervals until satisfied or timeout. Conditions: text (text appears), text_gone (text disappears), selector (CSS selector matches), selector_gone, url (URL contains value), ipc_idle (no pending IPC calls), network_idle (no pending network requests).",
542        annotations(
543            read_only_hint = true,
544            destructive_hint = false,
545            idempotent_hint = true,
546            open_world_hint = false
547        )
548    )]
549    async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
550        let value = params
551            .value
552            .as_ref()
553            .map_or_else(|| "null".to_string(), |v| js_string(v));
554        let timeout_ms = params.timeout_ms.unwrap_or(10_000).min(60_000);
555        let poll = params.poll_ms.unwrap_or(200);
556        let code = format!(
557            "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
558            js_string(params.condition.as_str())
559        );
560        let eval_timeout = std::time::Duration::from_millis(timeout_ms + 5000);
561        match self
562            .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
563            .await
564        {
565            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
566            Err(e) => tool_error(e),
567        }
568    }
569
570    #[tool(
571        description = "Run a semantic assertion: evaluate a JS expression and check the result against an expected condition. Conditions: equals, not_equals, contains, greater_than, less_than, truthy, falsy, exists, type_is.",
572        annotations(
573            read_only_hint = true,
574            destructive_hint = false,
575            idempotent_hint = true,
576            open_world_hint = false
577        )
578    )]
579    async fn assert_semantic(
580        &self,
581        Parameters(params): Parameters<SemanticAssertParams>,
582    ) -> CallToolResult {
583        if !self.state.privacy.is_tool_enabled("eval_js") {
584            return tool_disabled("assert_semantic requires eval_js capability");
585        }
586        let code = format!("return ({})", params.expression);
587        let actual_json = match self
588            .eval_with_return(&code, params.webview_label.as_deref())
589            .await
590        {
591            Ok(result) => result,
592            Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
593        };
594
595        let actual: serde_json::Value = match serde_json::from_str(&actual_json) {
596            Ok(v) => v,
597            Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
598        };
599
600        let assertion = victauri_core::SemanticAssertion {
601            label: params.label,
602            condition: params.condition,
603            expected: params.expected,
604        };
605
606        let result = victauri_core::evaluate_assertion(actual, &assertion);
607        json_result(&result)
608    }
609
610    #[tool(
611        description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.",
612        annotations(
613            read_only_hint = true,
614            destructive_hint = false,
615            idempotent_hint = true,
616            open_world_hint = false
617        )
618    )]
619    async fn resolve_command(
620        &self,
621        Parameters(params): Parameters<ResolveCommandParams>,
622    ) -> CallToolResult {
623        self.track_tool_call();
624        let limit = params.limit.unwrap_or(5);
625        let mut results = self.state.registry.resolve(&params.query);
626        results.truncate(limit);
627        json_result(&results)
628    }
629
630    #[tool(
631        description = "List or search all registered Tauri commands with their argument schemas. Pass query to filter by name/description substring. Commands are registered via #[inspectable] macro.",
632        annotations(
633            read_only_hint = true,
634            destructive_hint = false,
635            idempotent_hint = true,
636            open_world_hint = false
637        )
638    )]
639    async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
640        self.track_tool_call();
641        let commands = match params.query {
642            Some(q) => self.state.registry.search(&q),
643            None => self.state.registry.list(),
644        };
645        json_result(&commands)
646    }
647
648    #[tool(
649        description = "Get real-time process memory statistics from the OS (working set, page file usage). On Windows returns detailed metrics; on Linux returns virtual/resident size.",
650        annotations(
651            read_only_hint = true,
652            destructive_hint = false,
653            idempotent_hint = true,
654            open_world_hint = false
655        )
656    )]
657    async fn get_memory_stats(&self) -> CallToolResult {
658        self.track_tool_call();
659        let stats = crate::memory::current_stats();
660        json_result(&stats)
661    }
662
663    #[tool(
664        description = "Inspect the Victauri plugin's own configuration: port, enabled/disabled tools, command filters, privacy settings, capacities, and version. Useful for agents to understand their capabilities before acting.",
665        annotations(
666            read_only_hint = true,
667            destructive_hint = false,
668            idempotent_hint = true,
669            open_world_hint = false
670        )
671    )]
672    async fn get_plugin_info(&self) -> CallToolResult {
673        self.track_tool_call();
674        let disabled: Vec<&str> = self
675            .state
676            .privacy
677            .disabled_tools
678            .iter()
679            .map(std::string::String::as_str)
680            .collect();
681        let blocklist: Vec<&str> = self
682            .state
683            .privacy
684            .command_blocklist
685            .iter()
686            .map(std::string::String::as_str)
687            .collect();
688        let allowlist: Option<Vec<&str>> = self
689            .state
690            .privacy
691            .command_allowlist
692            .as_ref()
693            .map(|s| s.iter().map(std::string::String::as_str).collect());
694        let all_tools = Self::tool_router().list_all();
695        let enabled_tools: Vec<&str> = all_tools
696            .iter()
697            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
698            .map(|t| t.name.as_ref())
699            .collect();
700
701        let result = serde_json::json!({
702            "version": env!("CARGO_PKG_VERSION"),
703            "bridge_version": BRIDGE_VERSION,
704            "port": self.state.port.load(Ordering::Relaxed),
705            "tools": {
706                "total": all_tools.len(),
707                "enabled": enabled_tools.len(),
708                "enabled_list": enabled_tools,
709                "disabled_list": disabled,
710            },
711            "commands": {
712                "allowlist": allowlist,
713                "blocklist": blocklist,
714            },
715            "privacy": {
716                "profile": self.state.privacy.profile.to_string(),
717                "redaction_enabled": self.state.privacy.redaction_enabled,
718            },
719            "capacities": {
720                "event_log": self.state.event_log.capacity(),
721                "eval_timeout_secs": self.state.eval_timeout.as_secs(),
722            },
723            "registered_commands": self.state.registry.count(),
724            "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
725            "uptime_secs": self.state.started_at.elapsed().as_secs(),
726        });
727        json_result(&result)
728    }
729
730    #[tool(
731        description = "Run environment diagnostics: detect service workers (break IPC interception), closed shadow DOM (invisible to snapshots), iframes (bridge absent), large DOM warnings, and CSP status. Call this first when connecting to an unfamiliar app.",
732        annotations(
733            read_only_hint = true,
734            destructive_hint = false,
735            idempotent_hint = true,
736            open_world_hint = false
737        )
738    )]
739    async fn get_diagnostics(
740        &self,
741        Parameters(params): Parameters<DiagnosticsParams>,
742    ) -> CallToolResult {
743        self.eval_bridge(
744            "return window.__VICTAURI__?.getDiagnostics()",
745            params.webview_label.as_deref(),
746        )
747        .await
748    }
749
750    // ── Backend Access Tools ───────────────────────────────────────────────
751
752    #[tool(
753        description = "Get comprehensive app info: Tauri config (identifier, product name, version), app directory paths (data, config, log, local_data), process environment variables, and database files found in app directories. Provides direct backend context without going through the webview.",
754        annotations(
755            read_only_hint = true,
756            destructive_hint = false,
757            idempotent_hint = true,
758            open_world_hint = false
759        )
760    )]
761    async fn app_info(&self) -> CallToolResult {
762        self.track_tool_call();
763        let config = self.bridge.tauri_config();
764
765        let data_dir = self.bridge.app_data_dir().ok();
766        let config_dir = self.bridge.app_config_dir().ok();
767        let log_dir = self.bridge.app_log_dir().ok();
768        let local_data_dir = self.bridge.app_local_data_dir().ok();
769
770        let env_vars: std::collections::BTreeMap<String, String> = std::env::vars()
771            .filter(|(k, _)| {
772                let upper = k.to_uppercase();
773                SAFE_ENV_PREFIXES
774                    .iter()
775                    .any(|prefix| upper.starts_with(prefix))
776            })
777            .collect();
778
779        #[cfg(feature = "sqlite")]
780        let databases: Vec<String> = data_dir
781            .as_ref()
782            .map(|d| {
783                crate::database::discover_databases(d)
784                    .into_iter()
785                    .filter_map(|p| {
786                        p.strip_prefix(d)
787                            .ok()
788                            .map(|rel| rel.to_string_lossy().into_owned())
789                    })
790                    .collect()
791            })
792            .unwrap_or_default();
793
794        #[cfg(not(feature = "sqlite"))]
795        let databases: Vec<String> = Vec::new();
796
797        let result = serde_json::json!({
798            "config": config,
799            "paths": {
800                "data": data_dir.as_ref().map(|p| p.to_string_lossy()),
801                "config": config_dir.as_ref().map(|p| p.to_string_lossy()),
802                "log": log_dir.as_ref().map(|p| p.to_string_lossy()),
803                "local_data": local_data_dir.as_ref().map(|p| p.to_string_lossy()),
804            },
805            "databases": databases,
806            "env": env_vars,
807            "process": {
808                "pid": std::process::id(),
809                "arch": std::env::consts::ARCH,
810                "os": std::env::consts::OS,
811                "family": std::env::consts::FAMILY,
812            },
813        });
814        json_result(&result)
815    }
816
817    #[tool(
818        description = "List files in the app's data, config, log, or local_data directories. Useful for discovering databases, config files, logs, and cached data on the backend — without going through the webview.",
819        annotations(
820            read_only_hint = true,
821            destructive_hint = false,
822            idempotent_hint = true,
823            open_world_hint = false
824        )
825    )]
826    async fn list_app_dir(
827        &self,
828        Parameters(params): Parameters<ListAppDirParams>,
829    ) -> CallToolResult {
830        self.track_tool_call();
831        let base = match self.resolve_app_dir(params.directory) {
832            Ok(d) => d,
833            Err(e) => return tool_error(e),
834        };
835
836        let target = if let Some(ref sub) = params.path {
837            let resolved = base.join(sub);
838            if !resolved.exists() {
839                return tool_error(format!("directory does not exist: {}", resolved.display()));
840            }
841            if let Err(e) = Self::safe_within(&base, &resolved) {
842                return tool_error(e);
843            }
844            resolved
845        } else {
846            base.clone()
847        };
848
849        if !target.exists() {
850            return tool_error(format!("directory does not exist: {}", target.display()));
851        }
852
853        let max_depth = params.max_depth.unwrap_or(1).min(5);
854        let pattern = params.pattern.as_deref();
855        let mut entries = Vec::new();
856
857        Self::list_dir_recursive(&target, &base, 0, max_depth, pattern, &mut entries);
858
859        json_result(&serde_json::json!({
860            "base": base.to_string_lossy(),
861            "path": params.path.unwrap_or_default(),
862            "entries": entries,
863            "count": entries.len(),
864        }))
865    }
866
867    #[tool(
868        description = "Read a file from the app's data, config, log, or local_data directory. Returns UTF-8 text by default, or base64 for binary files. Directly reads backend files without going through the webview.",
869        annotations(
870            read_only_hint = true,
871            destructive_hint = false,
872            idempotent_hint = true,
873            open_world_hint = false
874        )
875    )]
876    async fn read_app_file(
877        &self,
878        Parameters(params): Parameters<ReadAppFileParams>,
879    ) -> CallToolResult {
880        self.track_tool_call();
881        let base = match self.resolve_app_dir(params.directory) {
882            Ok(d) => d,
883            Err(e) => return tool_error(e),
884        };
885
886        let target = base.join(&params.path);
887        if !target.exists() {
888            return tool_error(format!("file not found: {}", params.path));
889        }
890        if let Err(e) = Self::safe_within(&base, &target) {
891            return tool_error(e);
892        }
893        if !target.is_file() {
894            return tool_error(format!("not a file: {}", params.path));
895        }
896
897        let max_bytes = params.max_bytes.unwrap_or(1_048_576).min(10_485_760);
898        let metadata = std::fs::metadata(&target).map_err(|e| e.to_string());
899
900        match std::fs::read(&target) {
901            Ok(mut bytes) => {
902                let original_size = bytes.len();
903                let truncated = bytes.len() > max_bytes;
904                if truncated {
905                    bytes.truncate(max_bytes);
906                }
907
908                let file_info = serde_json::json!({
909                    "path": params.path,
910                    "size": original_size,
911                    "truncated": truncated,
912                    "modified": metadata.as_ref().ok()
913                        .and_then(|m| m.modified().ok())
914                        .map(|t| {
915                            let duration = t.duration_since(std::time::SystemTime::UNIX_EPOCH).unwrap_or_default();
916                            duration.as_secs()
917                        }),
918                });
919
920                if params.binary == Some(true) {
921                    use base64::Engine;
922                    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
923                    json_result(&serde_json::json!({
924                        "file": file_info,
925                        "encoding": "base64",
926                        "content": b64,
927                    }))
928                } else {
929                    match String::from_utf8(bytes) {
930                        Ok(text) => json_result(&serde_json::json!({
931                            "file": file_info,
932                            "encoding": "utf-8",
933                            "content": text,
934                        })),
935                        Err(e) => {
936                            use base64::Engine;
937                            let bytes = e.into_bytes();
938                            let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
939                            json_result(&serde_json::json!({
940                                "file": file_info,
941                                "encoding": "base64",
942                                "note": "file is not valid UTF-8, returning base64",
943                                "content": b64,
944                            }))
945                        }
946                    }
947                }
948            }
949            Err(e) => tool_error(format!("failed to read file: {e}")),
950        }
951    }
952
953    #[cfg(feature = "sqlite")]
954    #[tool(
955        description = "Execute a read-only SQL query against a SQLite database in the app's data directory. Auto-discovers database files if no path is specified. Only SELECT/PRAGMA/EXPLAIN/WITH queries are allowed. Returns rows as JSON objects with column names as keys. This provides direct backend database access without going through the webview or IPC.",
956        annotations(
957            read_only_hint = true,
958            destructive_hint = false,
959            idempotent_hint = true,
960            open_world_hint = false
961        )
962    )]
963    async fn query_db(&self, Parameters(params): Parameters<QueryDbParams>) -> CallToolResult {
964        self.track_tool_call();
965        let data_dir = match self.bridge.app_data_dir() {
966            Ok(d) => d,
967            Err(e) => return tool_error(format!("cannot access app data directory: {e}")),
968        };
969
970        let app_dirs: Vec<std::path::PathBuf> = [
971            self.bridge.app_data_dir(),
972            self.bridge.app_config_dir(),
973            self.bridge.app_local_data_dir(),
974            self.bridge.app_log_dir(),
975        ]
976        .into_iter()
977        .filter_map(Result::ok)
978        .collect::<std::collections::HashSet<_>>()
979        .into_iter()
980        .collect();
981        // Explicitly-configured roots (VictauriBuilder::db_search_paths) take
982        // precedence over OS app directories for auto-discovery, so a configured
983        // application DB wins over incidental ones (e.g. WebView internals).
984        let mut search_dirs: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
985        search_dirs.extend(app_dirs);
986
987        let db_path = if let Some(ref rel_path) = params.path {
988            let candidate = std::path::Path::new(rel_path);
989            // Absolute paths are permitted only when they resolve within one of
990            // the allowed roots (app directories or configured db_search_paths).
991            if candidate.is_absolute() {
992                if !candidate.exists() {
993                    return tool_error(format!("database not found: {rel_path}"));
994                }
995                if !search_dirs
996                    .iter()
997                    .any(|d| Self::safe_within(d, candidate).is_ok())
998                {
999                    return tool_error(format!(
1000                        "absolute path '{rel_path}' is not within an allowed directory; \
1001                         register its parent via VictauriBuilder::db_search_paths"
1002                    ));
1003                }
1004            }
1005            let mut found = None;
1006            if candidate.is_absolute() {
1007                found = Some(candidate.to_path_buf());
1008            } else {
1009                for dir in &search_dirs {
1010                    let resolved = dir.join(rel_path);
1011                    if resolved.exists() {
1012                        if let Err(e) = Self::safe_within(dir, &resolved) {
1013                            return tool_error(e);
1014                        }
1015                        found = Some(resolved);
1016                        break;
1017                    }
1018                }
1019            }
1020            if let Some(p) = found {
1021                p
1022            } else {
1023                let dirs_str = search_dirs
1024                    .iter()
1025                    .map(|d| d.display().to_string())
1026                    .collect::<Vec<_>>()
1027                    .join(", ");
1028                return tool_error(format!(
1029                    "database not found: {rel_path} (searched: {dirs_str})"
1030                ));
1031            }
1032        } else {
1033            let mut databases = Vec::new();
1034            for dir in &search_dirs {
1035                databases.extend(crate::database::discover_databases(dir));
1036            }
1037            if let Some(p) = databases.first() {
1038                p.clone()
1039            } else {
1040                let dirs_str = search_dirs
1041                    .iter()
1042                    .map(|d| d.display().to_string())
1043                    .collect::<Vec<_>>()
1044                    .join(", ");
1045                return tool_error(format!("no SQLite databases found in: {dirs_str}"));
1046            }
1047        };
1048
1049        let db_display = db_path
1050            .strip_prefix(&data_dir)
1051            .unwrap_or(&db_path)
1052            .to_string_lossy()
1053            .into_owned();
1054        let bind_params = params.params.unwrap_or_default();
1055
1056        match crate::database::query(&db_path, &params.query, &bind_params, params.max_rows) {
1057            Ok(mut result) => {
1058                if let Some(obj) = result.as_object_mut() {
1059                    obj.insert("database".to_string(), serde_json::json!(db_display));
1060                }
1061                json_result(&result)
1062            }
1063            Err(e) => tool_error(e),
1064        }
1065    }
1066
1067    // ── Compound Tools ──────────────────────────────────────────────────────
1068
1069    #[tool(
1070        description = "DOM element interactions. Actions: click, double_click, hover, focus, scroll_into_view, select_option. Requires ref_id from a dom_snapshot for most actions.",
1071        annotations(
1072            read_only_hint = false,
1073            destructive_hint = false,
1074            idempotent_hint = false,
1075            open_world_hint = false
1076        )
1077    )]
1078    async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
1079        if !self.state.privacy.is_tool_enabled("interact") {
1080            return tool_disabled("interact");
1081        }
1082        match params.action {
1083            InteractAction::Click => {
1084                if !self.state.privacy.is_tool_enabled("interact.click") {
1085                    return tool_disabled("interact.click");
1086                }
1087                let Some(ref_id) = &params.ref_id else {
1088                    return missing_param("ref_id", "click");
1089                };
1090                if params.trusted.unwrap_or(false) {
1091                    // Resolve the element's viewport-center coords, run the
1092                    // actionability check, then deliver a real OS click.
1093                    let probe = format!(
1094                        "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); \
1095                         if(!__e) return null; __e.scrollIntoView({{block:'center',inline:'center',behavior:'instant'}}); \
1096                         var __b=__e.getBoundingClientRect(); \
1097                         return {{x:__b.left+__b.width/2, y:__b.top+__b.height/2}}",
1098                        js_string(ref_id)
1099                    );
1100                    let raw = match self
1101                        .eval_with_return(&probe, params.webview_label.as_deref())
1102                        .await
1103                    {
1104                        Ok(r) => r,
1105                        Err(e) => return tool_error(e),
1106                    };
1107                    let Ok(point) = serde_json::from_str::<serde_json::Value>(&raw) else {
1108                        return tool_error_with_hint(
1109                            format!("ref not found: {ref_id}"),
1110                            RecoveryHint::CheckInput,
1111                        );
1112                    };
1113                    let (Some(x), Some(y)) = (
1114                        point.get("x").and_then(serde_json::Value::as_f64),
1115                        point.get("y").and_then(serde_json::Value::as_f64),
1116                    ) else {
1117                        return tool_error_with_hint(
1118                            format!("ref not found: {ref_id}"),
1119                            RecoveryHint::CheckInput,
1120                        );
1121                    };
1122                    return match self
1123                        .bridge
1124                        .native_click(params.webview_label.as_deref(), x, y)
1125                    {
1126                        Ok(()) => json_result(
1127                            &serde_json::json!({"ok": true, "trusted": true, "x": x, "y": y}),
1128                        ),
1129                        Err(e) => tool_error(e),
1130                    };
1131                }
1132                let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
1133                self.eval_bridge(&code, params.webview_label.as_deref())
1134                    .await
1135            }
1136            InteractAction::DoubleClick => {
1137                if !self.state.privacy.is_tool_enabled("interact.double_click") {
1138                    return tool_disabled("interact.double_click");
1139                }
1140                let Some(ref_id) = &params.ref_id else {
1141                    return missing_param("ref_id", "double_click");
1142                };
1143                let code = format!(
1144                    "return window.__VICTAURI__?.doubleClick({})",
1145                    js_string(ref_id)
1146                );
1147                self.eval_bridge(&code, params.webview_label.as_deref())
1148                    .await
1149            }
1150            InteractAction::Hover => {
1151                if !self.state.privacy.is_tool_enabled("interact.hover") {
1152                    return tool_disabled("interact.hover");
1153                }
1154                let Some(ref_id) = &params.ref_id else {
1155                    return missing_param("ref_id", "hover");
1156                };
1157                let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
1158                self.eval_bridge(&code, params.webview_label.as_deref())
1159                    .await
1160            }
1161            InteractAction::Focus => {
1162                if !self.state.privacy.is_tool_enabled("interact.focus") {
1163                    return tool_disabled("interact.focus");
1164                }
1165                let Some(ref_id) = &params.ref_id else {
1166                    return missing_param("ref_id", "focus");
1167                };
1168                let code = format!(
1169                    "return window.__VICTAURI__?.focusElement({})",
1170                    js_string(ref_id)
1171                );
1172                self.eval_bridge(&code, params.webview_label.as_deref())
1173                    .await
1174            }
1175            InteractAction::ScrollIntoView => {
1176                if !self
1177                    .state
1178                    .privacy
1179                    .is_tool_enabled("interact.scroll_into_view")
1180                {
1181                    return tool_disabled("interact.scroll_into_view");
1182                }
1183                let ref_arg = params
1184                    .ref_id
1185                    .as_ref()
1186                    .map_or_else(|| "null".to_string(), |r| js_string(r));
1187                let x = params.x.unwrap_or(0.0);
1188                let y = params.y.unwrap_or(0.0);
1189                let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
1190                self.eval_bridge(&code, params.webview_label.as_deref())
1191                    .await
1192            }
1193            InteractAction::SelectOption => {
1194                if !self.state.privacy.is_tool_enabled("interact.select_option") {
1195                    return tool_disabled("interact.select_option");
1196                }
1197                let Some(ref_id) = &params.ref_id else {
1198                    return missing_param("ref_id", "select_option");
1199                };
1200                let values_vec;
1201                let values: &[String] = match (&params.values, &params.value) {
1202                    (Some(v), _) => v,
1203                    (None, Some(v)) => {
1204                        values_vec = vec![v.clone()];
1205                        &values_vec
1206                    }
1207                    (None, None) => &[],
1208                };
1209                let values_json =
1210                    serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
1211                let code = format!(
1212                    "return window.__VICTAURI__?.selectOption({}, {})",
1213                    js_string(ref_id),
1214                    values_json
1215                );
1216                self.eval_bridge(&code, params.webview_label.as_deref())
1217                    .await
1218            }
1219        }
1220    }
1221
1222    #[tool(
1223        description = "Text and keyboard input. Actions: fill (set input value), type_text (character-by-character typing), press_key (trigger a keyboard key). Subject to privacy controls.",
1224        annotations(
1225            read_only_hint = false,
1226            destructive_hint = false,
1227            idempotent_hint = false,
1228            open_world_hint = false
1229        )
1230    )]
1231    async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
1232        match params.action {
1233            InputAction::Fill => {
1234                if !self.state.privacy.is_tool_enabled("fill") {
1235                    return tool_disabled("fill");
1236                }
1237                let Some(ref_id) = &params.ref_id else {
1238                    return missing_param("ref_id", "fill");
1239                };
1240                let Some(value) = &params.value else {
1241                    return missing_param("value", "fill");
1242                };
1243                let code = format!(
1244                    "return window.__VICTAURI__?.fill({}, {})",
1245                    js_string(ref_id),
1246                    js_string(value)
1247                );
1248                self.eval_bridge(&code, params.webview_label.as_deref())
1249                    .await
1250            }
1251            InputAction::TypeText => {
1252                if !self.state.privacy.is_tool_enabled("type_text") {
1253                    return tool_disabled("type_text");
1254                }
1255                let Some(ref_id) = &params.ref_id else {
1256                    return missing_param("ref_id", "type_text");
1257                };
1258                let Some(text) = &params.text else {
1259                    return missing_param("text", "type_text");
1260                };
1261                if params.trusted.unwrap_or(false) {
1262                    // Focus the element via JS, then deliver real OS keystrokes
1263                    // (isTrusted: true) for handlers that reject synthetic events.
1264                    let focus = format!(
1265                        "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); if(__e){{__e.focus();}} return !!__e",
1266                        js_string(ref_id)
1267                    );
1268                    let focused = self
1269                        .eval_with_return(&focus, params.webview_label.as_deref())
1270                        .await
1271                        .unwrap_or_default();
1272                    if focused != "true" {
1273                        return tool_error_with_hint(
1274                            format!("ref not found or not focusable: {ref_id}"),
1275                            RecoveryHint::CheckInput,
1276                        );
1277                    }
1278                    return match self
1279                        .bridge
1280                        .native_type_text(params.webview_label.as_deref(), text)
1281                    {
1282                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1283                        Err(e) => tool_error(e),
1284                    };
1285                }
1286                let code = format!(
1287                    "return window.__VICTAURI__?.type({}, {})",
1288                    js_string(ref_id),
1289                    js_string(text)
1290                );
1291                self.eval_bridge(&code, params.webview_label.as_deref())
1292                    .await
1293            }
1294            InputAction::PressKey => {
1295                if !self.state.privacy.is_tool_enabled("input.press_key") {
1296                    return tool_disabled("input.press_key");
1297                }
1298                let Some(key) = &params.key else {
1299                    return missing_param("key", "press_key");
1300                };
1301                if params.trusted.unwrap_or(false) {
1302                    // Optionally focus a target element, then send a real OS key.
1303                    if let Some(ref_id) = &params.ref_id {
1304                        let focus = format!(
1305                            "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); if(__e){{__e.focus();}} return !!__e",
1306                            js_string(ref_id)
1307                        );
1308                        let _ = self
1309                            .eval_with_return(&focus, params.webview_label.as_deref())
1310                            .await;
1311                    }
1312                    return match self.bridge.native_key(params.webview_label.as_deref(), key) {
1313                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1314                        Err(e) => tool_error(e),
1315                    };
1316                }
1317                let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
1318                self.eval_bridge(&code, params.webview_label.as_deref())
1319                    .await
1320            }
1321        }
1322    }
1323
1324    #[tool(
1325        description = "Window management. Actions: get_state (window positions/sizes/visibility), list (all window labels), manage (minimize/maximize/close/focus/show/hide/fullscreen/always_on_top), resize, move_to, set_title, introspectability (probe every window and report which Victauri can actually see — a visible window that comes back introspectable:false is almost always missing the \"victauri:default\" capability; run this FIRST when eval_js/dom_snapshot/animation return nothing for a multi-window app).",
1326        annotations(
1327            read_only_hint = false,
1328            destructive_hint = false,
1329            idempotent_hint = true,
1330            open_world_hint = false
1331        )
1332    )]
1333    async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
1334        self.track_tool_call();
1335        match params.action {
1336            WindowAction::GetState => {
1337                let states = self.bridge.get_window_states(params.label.as_deref());
1338                // A specific label that matches no window is an error, not an
1339                // empty array (which reads as "success, no state").
1340                if states.is_empty()
1341                    && let Some(label) = params.label.as_deref()
1342                {
1343                    return tool_error(format!(
1344                        "window not found: '{label}' (use window.list to see available labels)"
1345                    ));
1346                }
1347                json_result(&states)
1348            }
1349            WindowAction::List => {
1350                let labels = self.bridge.list_window_labels();
1351                json_result(&labels)
1352            }
1353            WindowAction::Introspectability => self.window_introspectability().await,
1354            WindowAction::Manage => {
1355                if !self.state.privacy.is_tool_enabled("window.manage") {
1356                    return tool_disabled("window.manage");
1357                }
1358                let Some(manage_action) = &params.manage_action else {
1359                    return missing_param("manage_action", "manage");
1360                };
1361                match self
1362                    .bridge
1363                    .manage_window(params.label.as_deref(), manage_action.as_str())
1364                {
1365                    Ok(msg) => CallToolResult::success(vec![Content::text(msg)]),
1366                    Err(e) => tool_error(e),
1367                }
1368            }
1369            WindowAction::Resize => {
1370                if !self.state.privacy.is_tool_enabled("window.resize") {
1371                    return tool_disabled("window.resize");
1372                }
1373                let Some(width) = params.width else {
1374                    return missing_param("width", "resize");
1375                };
1376                let Some(height) = params.height else {
1377                    return missing_param("height", "resize");
1378                };
1379                if width == 0 || height == 0 {
1380                    return tool_error_with_hint(
1381                        format!(
1382                            "invalid window size {width}x{height}: width and height must be > 0"
1383                        ),
1384                        RecoveryHint::CheckInput,
1385                    );
1386                }
1387                match self
1388                    .bridge
1389                    .resize_window(params.label.as_deref(), width, height)
1390                {
1391                    Ok(()) => {
1392                        let result =
1393                            serde_json::json!({"ok": true, "width": width, "height": height});
1394                        CallToolResult::success(vec![Content::text(result.to_string())])
1395                    }
1396                    Err(e) => tool_error(e),
1397                }
1398            }
1399            WindowAction::MoveTo => {
1400                if !self.state.privacy.is_tool_enabled("window.move_to") {
1401                    return tool_disabled("window.move_to");
1402                }
1403                let Some(x) = params.x else {
1404                    return missing_param("x", "move_to");
1405                };
1406                let Some(y) = params.y else {
1407                    return missing_param("y", "move_to");
1408                };
1409                match self.bridge.move_window(params.label.as_deref(), x, y) {
1410                    Ok(()) => {
1411                        let result = serde_json::json!({"ok": true, "x": x, "y": y});
1412                        CallToolResult::success(vec![Content::text(result.to_string())])
1413                    }
1414                    Err(e) => tool_error(e),
1415                }
1416            }
1417            WindowAction::SetTitle => {
1418                if !self.state.privacy.is_tool_enabled("window.set_title") {
1419                    return tool_disabled("window.set_title");
1420                }
1421                let Some(title) = &params.title else {
1422                    return missing_param("title", "set_title");
1423                };
1424                match self.bridge.set_window_title(params.label.as_deref(), title) {
1425                    Ok(()) => {
1426                        let result = serde_json::json!({"ok": true, "title": title});
1427                        CallToolResult::success(vec![Content::text(result.to_string())])
1428                    }
1429                    Err(e) => tool_error(e),
1430                }
1431            }
1432        }
1433    }
1434
1435    #[tool(
1436        description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
1437        annotations(
1438            read_only_hint = false,
1439            destructive_hint = true,
1440            idempotent_hint = false,
1441            open_world_hint = false
1442        )
1443    )]
1444    async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
1445        match params.action {
1446            StorageAction::Get => {
1447                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1448                    StorageType::Session => "getSessionStorage",
1449                    StorageType::Local => "getLocalStorage",
1450                };
1451                let key_arg = params
1452                    .key
1453                    .as_ref()
1454                    .map(|k| js_string(k))
1455                    .unwrap_or_default();
1456                let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
1457                self.eval_bridge(&code, params.webview_label.as_deref())
1458                    .await
1459            }
1460            StorageAction::Set => {
1461                if !self.state.privacy.is_tool_enabled("set_storage") {
1462                    return tool_disabled("set_storage");
1463                }
1464                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1465                    StorageType::Session => "setSessionStorage",
1466                    StorageType::Local => "setLocalStorage",
1467                };
1468                let Some(key) = &params.key else {
1469                    return missing_param("key", "set");
1470                };
1471                let value = params
1472                    .value
1473                    .as_ref()
1474                    .cloned()
1475                    .unwrap_or(serde_json::Value::Null);
1476                let value_json =
1477                    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1478                let code = format!(
1479                    "return window.__VICTAURI__?.{method}({}, {value_json})",
1480                    js_string(key)
1481                );
1482                self.eval_bridge(&code, params.webview_label.as_deref())
1483                    .await
1484            }
1485            StorageAction::Delete => {
1486                if !self.state.privacy.is_tool_enabled("delete_storage") {
1487                    return tool_disabled("delete_storage");
1488                }
1489                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1490                    StorageType::Session => "deleteSessionStorage",
1491                    StorageType::Local => "deleteLocalStorage",
1492                };
1493                let Some(key) = &params.key else {
1494                    return missing_param("key", "delete");
1495                };
1496                let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
1497                self.eval_bridge(&code, params.webview_label.as_deref())
1498                    .await
1499            }
1500            StorageAction::GetCookies => {
1501                self.eval_bridge(
1502                    "return window.__VICTAURI__?.getCookies()",
1503                    params.webview_label.as_deref(),
1504                )
1505                .await
1506            }
1507        }
1508    }
1509
1510    #[tool(
1511        description = "Navigation and dialog control. Actions: go_to (navigate to URL), go_back (browser back), get_history (navigation log), set_dialog_response (auto-respond to alert/confirm/prompt), get_dialog_log (captured dialog events). Subject to privacy controls for go_to and set_dialog_response.",
1512        annotations(
1513            read_only_hint = false,
1514            destructive_hint = false,
1515            idempotent_hint = false,
1516            open_world_hint = false
1517        )
1518    )]
1519    async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
1520        match params.action {
1521            NavigateAction::GoTo => {
1522                if !self.state.privacy.is_tool_enabled("navigate") {
1523                    return tool_disabled("navigate");
1524                }
1525                let Some(url) = &params.url else {
1526                    return missing_param("url", "go_to");
1527                };
1528                if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
1529                    return tool_error(e);
1530                }
1531                let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
1532                self.eval_bridge(&code, params.webview_label.as_deref())
1533                    .await
1534            }
1535            NavigateAction::GoBack => {
1536                self.eval_bridge(
1537                    "return window.__VICTAURI__?.navigateBack()",
1538                    params.webview_label.as_deref(),
1539                )
1540                .await
1541            }
1542            NavigateAction::GetHistory => {
1543                self.eval_bridge(
1544                    "return window.__VICTAURI__?.getNavigationLog()",
1545                    params.webview_label.as_deref(),
1546                )
1547                .await
1548            }
1549            NavigateAction::SetDialogResponse => {
1550                if !self.state.privacy.is_tool_enabled("set_dialog_response") {
1551                    return tool_disabled("set_dialog_response");
1552                }
1553                let Some(dialog_type) = params.dialog_type else {
1554                    return missing_param("dialog_type", "set_dialog_response");
1555                };
1556                let Some(dialog_action) = params.dialog_action else {
1557                    return missing_param("dialog_action", "set_dialog_response");
1558                };
1559                let text_arg = params
1560                    .text
1561                    .as_ref()
1562                    .map_or_else(|| "undefined".to_string(), |t| js_string(t));
1563                let code = format!(
1564                    "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})",
1565                    js_string(dialog_type.as_str()),
1566                    js_string(dialog_action.as_str())
1567                );
1568                self.eval_bridge(&code, params.webview_label.as_deref())
1569                    .await
1570            }
1571            NavigateAction::GetDialogLog => {
1572                self.eval_bridge(
1573                    "return window.__VICTAURI__?.getDialogLog()",
1574                    params.webview_label.as_deref(),
1575                )
1576                .await
1577            }
1578        }
1579    }
1580
1581    #[tool(
1582        description = "Time-travel recording. Actions: start (begin recording), stop (end and return session), checkpoint (save state snapshot), list_checkpoints, get_events (since index), events_between (two checkpoints), get_replay (IPC replay sequence), export (session as JSON), import (load session from JSON), replay (re-execute recorded IPC commands and compare responses), flush (immediately drain pending events into recording without waiting for the 1-second poll).",
1583        annotations(
1584            read_only_hint = false,
1585            destructive_hint = false,
1586            idempotent_hint = false,
1587            open_world_hint = false
1588        )
1589    )]
1590    async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
1591        const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
1592        self.track_tool_call();
1593        if !self.state.privacy.is_tool_enabled("recording") {
1594            return tool_disabled("recording");
1595        }
1596        match params.action {
1597            RecordingAction::Start => {
1598                let session_id = params
1599                    .session_id
1600                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1601                match self.state.recorder.start(session_id.clone()) {
1602                    Ok(()) => {
1603                        let result = serde_json::json!({
1604                            "started": true,
1605                            "session_id": session_id,
1606                        });
1607                        CallToolResult::success(vec![Content::text(result.to_string())])
1608                    }
1609                    Err(e) => tool_error(e.to_string()),
1610                }
1611            }
1612            RecordingAction::Stop => match self.state.recorder.stop() {
1613                Some(session) => json_result(&session),
1614                None => tool_error("no recording is active"),
1615            },
1616            RecordingAction::Checkpoint => {
1617                let Some(id) = params.checkpoint_id else {
1618                    return missing_param("checkpoint_id", "checkpoint");
1619                };
1620                let state = params.state.unwrap_or(serde_json::Value::Null);
1621                match self
1622                    .state
1623                    .recorder
1624                    .checkpoint(id.clone(), params.checkpoint_label, state)
1625                {
1626                    Ok(()) => {
1627                        let result = serde_json::json!({
1628                            "created": true,
1629                            "checkpoint_id": id,
1630                            "event_index": self.state.recorder.event_count(),
1631                        });
1632                        CallToolResult::success(vec![Content::text(result.to_string())])
1633                    }
1634                    Err(e) => tool_error(e.to_string()),
1635                }
1636            }
1637            RecordingAction::ListCheckpoints => {
1638                let checkpoints = self.state.recorder.get_checkpoints();
1639                json_result(&checkpoints)
1640            }
1641            RecordingAction::GetEvents => {
1642                let events = self
1643                    .state
1644                    .recorder
1645                    .events_since(params.since_index.unwrap_or(0));
1646                json_result(&events)
1647            }
1648            RecordingAction::EventsBetween => {
1649                let Some(from) = &params.from else {
1650                    return missing_param("from", "events_between");
1651                };
1652                let Some(to) = &params.to else {
1653                    return missing_param("to", "events_between");
1654                };
1655                match self.state.recorder.events_between_checkpoints(from, to) {
1656                    Ok(events) => json_result(&events),
1657                    Err(e) => tool_error(e.to_string()),
1658                }
1659            }
1660            RecordingAction::GetReplay => {
1661                let calls = self.state.recorder.ipc_replay_sequence();
1662                json_result(&calls)
1663            }
1664            RecordingAction::Export => match self.state.recorder.export() {
1665                Some(s) => {
1666                    let json = serde_json::to_string_pretty(&s)
1667                        .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
1668                    CallToolResult::success(vec![Content::text(json)])
1669                }
1670                None => tool_error("no recording is active — start one first"),
1671            },
1672            RecordingAction::Import => {
1673                let Some(session_json) = &params.session_json else {
1674                    return missing_param("session_json", "import");
1675                };
1676                if session_json.len() > MAX_SESSION_JSON {
1677                    return tool_error("session JSON exceeds maximum size (10 MB)");
1678                }
1679                let session: victauri_core::RecordedSession =
1680                    match serde_json::from_str(session_json) {
1681                        Ok(s) => s,
1682                        Err(e) => return tool_error(format!("invalid session JSON: {e}")),
1683                    };
1684
1685                let result = serde_json::json!({
1686                    "imported": true,
1687                    "session_id": session.id,
1688                    "event_count": session.events.len(),
1689                    "checkpoint_count": session.checkpoints.len(),
1690                    "started_at": session.started_at.to_rfc3339(),
1691                });
1692                self.state.recorder.import(session);
1693                CallToolResult::success(vec![Content::text(result.to_string())])
1694            }
1695            RecordingAction::Flush => {
1696                if !self.state.recorder.is_recording() {
1697                    return tool_error("no active recording — start a recording first");
1698                }
1699                let code = "return window.__VICTAURI__?.getEventStream(0)";
1700                match self
1701                    .eval_with_return(code, params.webview_label.as_deref())
1702                    .await
1703                {
1704                    Ok(result_str) => {
1705                        let events: Vec<serde_json::Value> =
1706                            serde_json::from_str(&result_str).unwrap_or_default();
1707                        let mut count = 0u64;
1708                        for ev in &events {
1709                            if let Some(app_event) = crate::mcp::server::parse_bridge_event(ev) {
1710                                self.state.event_log.push(app_event.clone());
1711                                self.state.recorder.record_event(app_event);
1712                                count += 1;
1713                            }
1714                        }
1715                        json_result(&serde_json::json!({
1716                            "flushed": true,
1717                            "events_captured": count,
1718                        }))
1719                    }
1720                    Err(e) => tool_error(format!("flush failed: {e}")),
1721                }
1722            }
1723            RecordingAction::Replay => {
1724                let calls = self.state.recorder.ipc_replay_sequence();
1725                if calls.is_empty() {
1726                    return tool_error("no IPC calls recorded — record a session first");
1727                }
1728                let mut replay_results = Vec::new();
1729                for call in &calls {
1730                    let code = format!(
1731                        "return window.__TAURI_INTERNALS__.invoke({})",
1732                        js_string(&call.command)
1733                    );
1734                    let outcome = match self
1735                        .eval_with_return(&code, params.webview_label.as_deref())
1736                        .await
1737                    {
1738                        Ok(result_str) => {
1739                            let value: serde_json::Value = serde_json::from_str(&result_str)
1740                                .unwrap_or(serde_json::Value::String(result_str));
1741                            let shape = crate::introspection::JsonShape::from_value(&value);
1742                            serde_json::json!({
1743                                "command": call.command,
1744                                "status": "ok",
1745                                "response_type": shape.type_name(),
1746                            })
1747                        }
1748                        Err(e) => {
1749                            serde_json::json!({
1750                                "command": call.command,
1751                                "status": "error",
1752                                "error": e,
1753                            })
1754                        }
1755                    };
1756                    replay_results.push(outcome);
1757                }
1758                let passed = replay_results
1759                    .iter()
1760                    .filter(|r| r.get("status").and_then(|s| s.as_str()) == Some("ok"))
1761                    .count();
1762                let result = serde_json::json!({
1763                    "replayed": replay_results.len(),
1764                    "passed": passed,
1765                    "failed": replay_results.len() - passed,
1766                    "results": replay_results,
1767                });
1768                json_result(&result)
1769            }
1770        }
1771    }
1772
1773    #[tool(
1774        description = "CSS and visual inspection. Actions: get_styles (computed CSS for element), get_bounding_boxes (layout rects), highlight (debug overlay), clear_highlights, audit_accessibility (a11y audit), get_performance (timing/heap/DOM metrics).",
1775        annotations(
1776            read_only_hint = true,
1777            destructive_hint = false,
1778            idempotent_hint = true,
1779            open_world_hint = false
1780        )
1781    )]
1782    async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
1783        match params.action {
1784            InspectAction::GetStyles => {
1785                let Some(ref_id) = &params.ref_id else {
1786                    return missing_param("ref_id", "get_styles");
1787                };
1788                let props_arg = match &params.properties {
1789                    Some(props) => {
1790                        let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
1791                        format!("[{}]", arr.join(","))
1792                    }
1793                    None => "null".to_string(),
1794                };
1795                let code = format!(
1796                    "return window.__VICTAURI__?.getStyles({}, {})",
1797                    js_string(ref_id),
1798                    props_arg
1799                );
1800                self.eval_bridge(&code, params.webview_label.as_deref())
1801                    .await
1802            }
1803            InspectAction::GetBoundingBoxes => {
1804                let Some(ref_ids) = &params.ref_ids else {
1805                    return missing_param("ref_ids", "get_bounding_boxes");
1806                };
1807                let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
1808                let code = format!(
1809                    "return window.__VICTAURI__?.getBoundingBoxes([{}])",
1810                    refs.join(",")
1811                );
1812                self.eval_bridge(&code, params.webview_label.as_deref())
1813                    .await
1814            }
1815            InspectAction::Highlight => {
1816                let Some(ref_id) = &params.ref_id else {
1817                    return missing_param("ref_id", "highlight");
1818                };
1819                let color_arg = match &params.color {
1820                    Some(c) => match sanitize_css_color(c) {
1821                        Ok(safe) => format!("\"{safe}\""),
1822                        Err(e) => return tool_error(e),
1823                    },
1824                    None => "null".to_string(),
1825                };
1826                let label_arg = match &params.label {
1827                    Some(l) => js_string(l),
1828                    None => "null".to_string(),
1829                };
1830                let code = format!(
1831                    "return window.__VICTAURI__?.highlightElement({}, {}, {})",
1832                    js_string(ref_id),
1833                    color_arg,
1834                    label_arg
1835                );
1836                self.eval_bridge(&code, params.webview_label.as_deref())
1837                    .await
1838            }
1839            InspectAction::ClearHighlights => {
1840                self.eval_bridge(
1841                    "return window.__VICTAURI__?.clearHighlights()",
1842                    params.webview_label.as_deref(),
1843                )
1844                .await
1845            }
1846            InspectAction::AuditAccessibility => {
1847                self.eval_bridge(
1848                    "return window.__VICTAURI__?.auditAccessibility()",
1849                    params.webview_label.as_deref(),
1850                )
1851                .await
1852            }
1853            InspectAction::GetPerformance => {
1854                self.eval_bridge(
1855                    "return window.__VICTAURI__?.getPerformanceMetrics()",
1856                    params.webview_label.as_deref(),
1857                )
1858                .await
1859            }
1860        }
1861    }
1862
1863    #[tool(
1864        description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
1865        annotations(
1866            read_only_hint = false,
1867            destructive_hint = false,
1868            idempotent_hint = true,
1869            open_world_hint = false
1870        )
1871    )]
1872    async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
1873        match params.action {
1874            CssAction::Inject => {
1875                if !self.state.privacy.is_tool_enabled("inject_css") {
1876                    return tool_disabled("inject_css");
1877                }
1878                let Some(css) = &params.css else {
1879                    return missing_param("css", "inject");
1880                };
1881                let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
1882                self.eval_bridge(&code, params.webview_label.as_deref())
1883                    .await
1884            }
1885            CssAction::Remove => {
1886                if !self.state.privacy.is_tool_enabled("css.remove") {
1887                    return tool_disabled("css.remove");
1888                }
1889                self.eval_bridge(
1890                    "return window.__VICTAURI__?.removeInjectedCss()",
1891                    params.webview_label.as_deref(),
1892                )
1893                .await
1894            }
1895        }
1896    }
1897
1898    #[tool(
1899        description = "Network request interception (Playwright route() equivalent, no CDP). \
1900            Matches webview fetch/XHR by URL and blocks, mocks, or delays them. \
1901            Actions:\n\
1902            - `add`: add a rule. `pattern` (+ optional `match_type`: substring/glob/regex/exact, \
1903              and `method`) selects requests; `behavior` is `block` (abort), `fulfill` (return a \
1904              mock `status`/`headers`/`body`/`content_type`), or `delay` (proceed after `delay_ms`). \
1905              `times` limits how often it fires. Rules are page-scoped (cleared on reload).\n\
1906            - `list`: list active rules.\n\
1907            - `clear` (by `id`) / `clear_all`: remove rules.\n\
1908            - `matches`: log of intercepted requests.\n\
1909            Note: fetch supports all behaviors; XHR supports block/delay (fulfill is fetch-only). \
1910            Top-level navigation, sub-resource (img/css), and WebSocket traffic are not intercepted. \
1911            Tauri IPC (ipc.localhost) is OBSERVE-ONLY: such calls appear in `matches`, but block/\
1912            fulfill/delay do NOT take effect on them — Tauri serves IPC below the JS fetch layer, so \
1913            it cannot be controlled cross-platform without CDP. There is no IPC-control tool; the \
1914            `fault` tool only affects commands you drive via `invoke_command`, not real user IPC.",
1915        annotations(
1916            read_only_hint = false,
1917            destructive_hint = false,
1918            idempotent_hint = false,
1919            open_world_hint = false
1920        )
1921    )]
1922    async fn route(&self, Parameters(params): Parameters<RouteParams>) -> CallToolResult {
1923        self.track_tool_call();
1924        match params.action {
1925            RouteAction::Add => {
1926                if !self.state.privacy.is_tool_enabled("route.add") {
1927                    return tool_disabled("route.add");
1928                }
1929                let Some(pattern) = &params.pattern else {
1930                    return missing_param("pattern", "add");
1931                };
1932                let behavior = params.behavior.unwrap_or(RouteBehavior::Fulfill);
1933                let match_type = params.match_type.unwrap_or(RouteMatchType::Substring);
1934                let mut rule = serde_json::json!({
1935                    "pattern": pattern,
1936                    "match_type": match_type.as_str(),
1937                    "action": behavior.as_str(),
1938                });
1939                if let Some(m) = &params.method {
1940                    rule["method"] = serde_json::json!(m);
1941                }
1942                if let Some(s) = params.status {
1943                    rule["status"] = serde_json::json!(s);
1944                }
1945                if let Some(st) = &params.status_text {
1946                    rule["status_text"] = serde_json::json!(st);
1947                }
1948                if let Some(h) = &params.headers {
1949                    rule["headers"] = h.clone();
1950                }
1951                if let Some(b) = &params.body {
1952                    // A JSON string body is passed through as-is; structured JSON
1953                    // is stringified so the bridge sends valid JSON text.
1954                    rule["body"] = match b {
1955                        serde_json::Value::String(s) => serde_json::json!(s),
1956                        other => serde_json::json!(other.to_string()),
1957                    };
1958                }
1959                if let Some(ct) = &params.content_type {
1960                    rule["content_type"] = serde_json::json!(ct);
1961                }
1962                if let Some(d) = params.delay_ms {
1963                    rule["delay_ms"] = serde_json::json!(d);
1964                }
1965                if let Some(t) = params.times {
1966                    rule["times"] = serde_json::json!(t);
1967                }
1968                let code = format!(
1969                    "return window.__VICTAURI__?.addRoute({})",
1970                    js_string(&rule.to_string())
1971                );
1972                self.eval_bridge(&code, params.webview_label.as_deref())
1973                    .await
1974            }
1975            RouteAction::List => {
1976                self.eval_bridge(
1977                    "return window.__VICTAURI__?.getRouteRules()",
1978                    params.webview_label.as_deref(),
1979                )
1980                .await
1981            }
1982            RouteAction::Clear => {
1983                let Some(id) = params.id else {
1984                    return missing_param("id", "clear");
1985                };
1986                let code = format!("return window.__VICTAURI__?.clearRoute({id})");
1987                self.eval_bridge(&code, params.webview_label.as_deref())
1988                    .await
1989            }
1990            RouteAction::ClearAll => {
1991                self.eval_bridge(
1992                    "return window.__VICTAURI__?.clearRoutes()",
1993                    params.webview_label.as_deref(),
1994                )
1995                .await
1996            }
1997            RouteAction::Matches => {
1998                let limit = params.limit.unwrap_or(100);
1999                let code = format!("return window.__VICTAURI__?.getRouteMatches({limit})");
2000                self.eval_bridge(&code, params.webview_label.as_deref())
2001                    .await
2002            }
2003        }
2004    }
2005
2006    #[tool(
2007        description = "Screencast / visual trace (no CDP). Captures the window at a fixed interval \
2008            into a ring buffer, forming a visual timeline that pairs with `recording` (events) and \
2009            `logs` (network/console). Actions:\n\
2010            - `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set \
2011              `with_events=true` to also start the event recorder.\n\
2012            - `stop`: stop and return a summary (frame count, duration, timestamps).\n\
2013            - `status`: active flag + buffered frame count.\n\
2014            - `frames`: return captured frames as base64 PNGs (`limit` caps how many).",
2015        annotations(
2016            read_only_hint = false,
2017            destructive_hint = false,
2018            idempotent_hint = false,
2019            open_world_hint = false
2020        )
2021    )]
2022    async fn trace(&self, Parameters(params): Parameters<TraceParams>) -> CallToolResult {
2023        self.track_tool_call();
2024        if !self.state.privacy.is_tool_enabled("trace")
2025            || !self.state.privacy.is_tool_enabled("screenshot")
2026        {
2027            return tool_disabled("trace");
2028        }
2029        match params.action {
2030            TraceAction::Start => {
2031                let interval = params.interval_ms.unwrap_or(500);
2032                let max_frames = params.max_frames.unwrap_or(60);
2033                let label = params.webview_label.clone();
2034                let generation = self
2035                    .state
2036                    .screencast
2037                    .start(interval, max_frames, label.clone());
2038
2039                let mut events_started = false;
2040                if params.with_events.unwrap_or(false) {
2041                    let session_id = uuid::Uuid::new_v4().to_string();
2042                    if self.state.recorder.start(session_id).is_ok() {
2043                        events_started = true;
2044                    }
2045                }
2046
2047                // Background capture task: snapshot the window each interval until
2048                // the screencast is stopped (or superseded by a newer start).
2049                let bridge = self.bridge.clone();
2050                let screencast = self.state.screencast.clone();
2051                tokio::spawn(async move {
2052                    let t0 = std::time::Instant::now();
2053                    while screencast.is_active() && screencast.generation() == generation {
2054                        if let Ok(handle) = bridge.get_native_handle(label.as_deref())
2055                            && let Ok(png) = crate::screenshot::capture_window(handle).await
2056                        {
2057                            use base64::Engine;
2058                            let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2059                            #[allow(clippy::cast_possible_truncation)]
2060                            screencast.push_frame(t0.elapsed().as_millis() as u64, b64);
2061                        }
2062                        tokio::time::sleep(std::time::Duration::from_millis(
2063                            screencast.interval_ms(),
2064                        ))
2065                        .await;
2066                    }
2067                });
2068
2069                json_result(&serde_json::json!({
2070                    "started": true,
2071                    "interval_ms": interval.max(50),
2072                    "max_frames": max_frames.clamp(1, 600),
2073                    "with_events": events_started,
2074                }))
2075            }
2076            TraceAction::Stop => {
2077                let frame_count = self.state.screencast.stop();
2078                let timestamps = self.state.screencast.frame_timestamps();
2079                let duration_ms = timestamps.last().copied().unwrap_or(0);
2080                let event_count = self.state.recorder.event_count();
2081                json_result(&serde_json::json!({
2082                    "stopped": true,
2083                    "frame_count": frame_count,
2084                    "duration_ms": duration_ms,
2085                    "frame_timestamps_ms": timestamps,
2086                    "recorded_event_count": event_count,
2087                    "hint": "use action=frames to retrieve PNGs; pair with recording/get_events and logs for a full bundle",
2088                }))
2089            }
2090            TraceAction::Status => json_result(&serde_json::json!({
2091                "active": self.state.screencast.is_active(),
2092                "frame_count": self.state.screencast.frame_count(),
2093                "interval_ms": self.state.screencast.interval_ms(),
2094            })),
2095            TraceAction::Frames => {
2096                let limit = params.limit.unwrap_or(0);
2097                let frames = self.state.screencast.frames(limit);
2098                let items: Vec<Content> = frames
2099                    .into_iter()
2100                    .map(|f| Content::image(f.data_b64, "image/png"))
2101                    .collect();
2102                if items.is_empty() {
2103                    return json_result(&serde_json::json!({ "frames": 0 }));
2104                }
2105                CallToolResult::success(items)
2106            }
2107        }
2108    }
2109
2110    #[tool(
2111        description = "Animation introspection (no CDP). Reads the Web Animations API to reveal what \
2112            the webview's animation engine is actually running — duration, delay, easing, iterations, \
2113            keyframes, current progress, and the animating element. Standard DOM, so it works \
2114            identically on WebView2/WKWebView/WebKitGTK. Actions:\n\
2115            - `list`: return all running CSS animations/transitions (optionally scoped by `selector`), \
2116              each with declared `timing`, `computed` progress, `keyframes`, and `target`.\n\
2117            - `scrub`: deterministically pause the target's animation and seek it to `points` \
2118              evenly-spaced steps (default 20), returning the exact geometry curve (rect + transform \
2119              + opacity per step). With `capture=true`, also returns a single contact-sheet filmstrip \
2120              PNG (one image of the whole arc) plus a `manifest` mapping each cell to its progress/time. \
2121              Frozen frames are jank-free, so this beats real-time capture for fast sweeps. CSS-driven \
2122              animations only (JS/rAF animations are not seekable — use `list`/`sample`).\n\
2123            - `sample`: real-time motion recorder. `record=true` arms a requestAnimationFrame watcher \
2124              on `selector` (or the first animating element); then trigger the animation; then call \
2125              with `record=false` to read the measured per-frame curve plus jank stats (dropped frames, \
2126              max frame gap) and declared-vs-measured duration. Works for ANY animation including \
2127              JS/rAF-driven ones. `clear=true` resets recorded sessions.\n\
2128            NOTE: an animation only appears while it is running or pending — trigger it (e.g. show the \
2129            notification) just before calling `list`/`scrub`, or arm `sample` before triggering.",
2130        annotations(
2131            read_only_hint = true,
2132            destructive_hint = false,
2133            idempotent_hint = true,
2134            open_world_hint = false
2135        )
2136    )]
2137    async fn animation(&self, Parameters(params): Parameters<AnimationParams>) -> CallToolResult {
2138        self.track_tool_call();
2139        if !self.state.privacy.is_tool_enabled("animation") {
2140            return tool_disabled("animation");
2141        }
2142        match params.action {
2143            AnimationAction::List => {
2144                let sel = params
2145                    .selector
2146                    .as_deref()
2147                    .map_or_else(|| "null".to_string(), js_string);
2148                let code = format!(
2149                    "return window.__VICTAURI__ && window.__VICTAURI__.listAnimations({sel})"
2150                );
2151                match self
2152                    .eval_with_return(&code, params.webview_label.as_deref())
2153                    .await
2154                {
2155                    Ok(result_str) => {
2156                        match serde_json::from_str::<serde_json::Value>(&result_str) {
2157                            Ok(v) => json_result(&v),
2158                            Err(_) => CallToolResult::success(vec![Content::text(result_str)]),
2159                        }
2160                    }
2161                    Err(e) => tool_error(format!("animation list failed: {e}")),
2162                }
2163            }
2164            AnimationAction::Scrub => self.animation_scrub(params).await,
2165            AnimationAction::Sample => {
2166                let label = params.webview_label.as_deref();
2167                let sel = params
2168                    .selector
2169                    .as_deref()
2170                    .map_or_else(|| "null".to_string(), js_string);
2171                let code = if params.record.unwrap_or(false) {
2172                    format!("return window.__VICTAURI__.installSweepRecorder({sel})")
2173                } else {
2174                    let clear = params.clear.unwrap_or(false);
2175                    format!("return window.__VICTAURI__.readSweep({clear})")
2176                };
2177                match self.eval_with_return(&code, label).await {
2178                    Ok(result_str) => {
2179                        match serde_json::from_str::<serde_json::Value>(&result_str) {
2180                            Ok(v) => json_result(&v),
2181                            Err(_) => CallToolResult::success(vec![Content::text(result_str)]),
2182                        }
2183                    }
2184                    Err(e) => tool_error(format!("animation sample failed: {e}")),
2185                }
2186            }
2187        }
2188    }
2189
2190    /// Deterministic pause-seek-capture loop for `animation scrub`. Split out to
2191    /// keep the `#[tool]` method readable.
2192    async fn animation_scrub(&self, params: AnimationParams) -> CallToolResult {
2193        let label = params.webview_label.as_deref();
2194        let sel = params
2195            .selector
2196            .as_deref()
2197            .map_or_else(|| "null".to_string(), js_string);
2198
2199        // 1. Prepare: pause the target's animations, learn the timeline length.
2200        let prep_code = format!("return await window.__VICTAURI__.scrubPrepare({sel})");
2201        let prep_v = match self.eval_with_return(&prep_code, label).await {
2202            Ok(s) => {
2203                serde_json::from_str::<serde_json::Value>(&s).unwrap_or(serde_json::Value::Null)
2204            }
2205            Err(e) => return tool_error(format!("scrub prepare failed: {e}")),
2206        };
2207        if prep_v.get("prepared").and_then(serde_json::Value::as_bool) != Some(true) {
2208            // Surface the helpful error/info object (no target, JS-driven, etc.).
2209            return json_result(&prep_v);
2210        }
2211
2212        let points = params.points.unwrap_or(20).clamp(2, 120);
2213        let capture = params.capture.unwrap_or(false);
2214        let mut curve: Vec<serde_json::Value> = Vec::with_capacity(points);
2215        let mut frames: Vec<crate::filmstrip::Frame> = Vec::new();
2216        let mut manifest: Vec<serde_json::Value> = Vec::new();
2217
2218        // 2. Seek to each evenly-spaced point; capture the frozen frame if asked.
2219        for i in 0..points {
2220            #[allow(clippy::cast_precision_loss)]
2221            let progress = i as f64 / (points - 1) as f64;
2222            let seek_code = format!("return await window.__VICTAURI__.scrubSeek({progress})");
2223            match self.eval_with_return(&seek_code, label).await {
2224                Ok(s) => {
2225                    let v = serde_json::from_str::<serde_json::Value>(&s)
2226                        .unwrap_or(serde_json::Value::Null);
2227                    if capture
2228                        && let Ok(handle) = self.bridge.get_native_handle(label)
2229                        && let Ok((rgba, w, h)) =
2230                            crate::screenshot::capture_window_raw(handle).await
2231                        && let Some(frame) = crate::filmstrip::Frame::new(rgba, w, h)
2232                    {
2233                        manifest.push(serde_json::json!({
2234                            "cell": frames.len(),
2235                            "progress": progress,
2236                            "t": v.get("t").cloned().unwrap_or(serde_json::Value::Null),
2237                        }));
2238                        frames.push(frame);
2239                    }
2240                    curve.push(v);
2241                }
2242                Err(e) => curve.push(serde_json::json!({ "progress": progress, "error": e })),
2243            }
2244        }
2245
2246        // 3. Restore (resume) or leave paused.
2247        let resume = params.restore.unwrap_or(true);
2248        let restore_code = format!("return window.__VICTAURI__.scrubRestore({resume})");
2249        let _ = self.eval_with_return(&restore_code, label).await;
2250
2251        let mut meta = serde_json::json!({
2252            "scrubbed": true,
2253            "points": points,
2254            "duration_ms": prep_v.get("duration").cloned().unwrap_or(serde_json::Value::Null),
2255            "anim_count": prep_v.get("anim_count").cloned().unwrap_or(serde_json::Value::Null),
2256            "target": prep_v.get("target").cloned().unwrap_or(serde_json::Value::Null),
2257            "captured": capture,
2258            "curve": curve,
2259        });
2260
2261        // 4. Compose the filmstrip if we captured frames.
2262        if capture && !frames.is_empty() {
2263            let cols = params
2264                .cols
2265                .unwrap_or_else(|| crate::filmstrip::default_cols(frames.len()));
2266            if let Some((rgba, w, h)) =
2267                crate::filmstrip::compose(&frames, cols, 4, [20, 20, 20, 255])
2268            {
2269                match crate::screenshot::encode_png(w, h, &rgba) {
2270                    Ok(png) => {
2271                        use base64::Engine;
2272                        let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2273                        meta["filmstrip"] = serde_json::json!({
2274                            "cols": cols,
2275                            "frame_count": frames.len(),
2276                            "width": w,
2277                            "height": h,
2278                            "manifest": manifest,
2279                        });
2280                        return CallToolResult::success(vec![
2281                            Content::image(b64, "image/png"),
2282                            Content::text(meta.to_string()),
2283                        ]);
2284                    }
2285                    Err(e) => return tool_error(format!("filmstrip encode failed: {e}")),
2286                }
2287            }
2288        }
2289
2290        json_result(&meta)
2291    }
2292
2293    #[tool(
2294        description = "Application logs and monitoring. Actions: console (captured console.log/warn/error), network (intercepted fetch/XHR), ipc (IPC call log — set wait_for_capture=true to await response capture up to 500ms), navigation (URL change history), dialogs (alert/confirm/prompt events), events (combined event stream), slow_ipc (find slow IPC calls).",
2295        annotations(
2296            read_only_hint = true,
2297            destructive_hint = false,
2298            idempotent_hint = true,
2299            open_world_hint = false
2300        )
2301    )]
2302    async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
2303        match params.action {
2304            LogsAction::Console => {
2305                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
2306                let base = if since_arg.is_empty() {
2307                    "window.__VICTAURI__?.getConsoleLogs()".to_string()
2308                } else {
2309                    format!("window.__VICTAURI__?.getConsoleLogs({since_arg})")
2310                };
2311                let code = if let Some(limit) = params.limit {
2312                    format!("return ({base} || []).slice(-{limit})")
2313                } else {
2314                    format!("return {base}")
2315                };
2316                self.eval_bridge(&code, params.webview_label.as_deref())
2317                    .await
2318            }
2319            LogsAction::Network => {
2320                let filter_arg = params
2321                    .filter
2322                    .as_ref()
2323                    .map_or_else(|| "null".to_string(), |f| js_string(f));
2324                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
2325                let source = format!("window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit})");
2326                let code = trimmed_log_js(&source, limit);
2327                self.eval_bridge(&code, params.webview_label.as_deref())
2328                    .await
2329            }
2330            LogsAction::Ipc => {
2331                let wait = params.wait_for_capture.unwrap_or(false);
2332                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
2333                if wait {
2334                    let inner = trimmed_log_js("window.__VICTAURI__.getIpcLog()", limit);
2335                    let code = format!(
2336                        r"return (async function() {{
2337                            await window.__VICTAURI__.waitForIpcComplete(500);
2338                            return (function() {{ {inner} }})();
2339                        }})()"
2340                    );
2341                    let timeout = std::time::Duration::from_millis(5000);
2342                    match self
2343                        .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
2344                        .await
2345                    {
2346                        Ok(result) => CallToolResult::success(vec![Content::text(result)]),
2347                        Err(e) => tool_error(e),
2348                    }
2349                } else {
2350                    let code = trimmed_log_js("window.__VICTAURI__?.getIpcLog()", limit);
2351                    self.eval_bridge(&code, params.webview_label.as_deref())
2352                        .await
2353                }
2354            }
2355            LogsAction::Navigation => {
2356                let code = if let Some(limit) = params.limit {
2357                    format!(
2358                        "return (window.__VICTAURI__?.getNavigationLog() || []).slice(-{limit})"
2359                    )
2360                } else {
2361                    "return window.__VICTAURI__?.getNavigationLog()".to_string()
2362                };
2363                self.eval_bridge(&code, params.webview_label.as_deref())
2364                    .await
2365            }
2366            LogsAction::Dialogs => {
2367                let code = if let Some(limit) = params.limit {
2368                    format!("return (window.__VICTAURI__?.getDialogLog() || []).slice(-{limit})")
2369                } else {
2370                    "return window.__VICTAURI__?.getDialogLog()".to_string()
2371                };
2372                self.eval_bridge(&code, params.webview_label.as_deref())
2373                    .await
2374            }
2375            LogsAction::Events => {
2376                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
2377                let base = if since_arg.is_empty() {
2378                    "window.__VICTAURI__?.getEventStream()".to_string()
2379                } else {
2380                    format!("window.__VICTAURI__?.getEventStream({since_arg})")
2381                };
2382                let code = if let Some(limit) = params.limit {
2383                    format!("return ({base} || []).slice(-{limit})")
2384                } else {
2385                    format!("return {base}")
2386                };
2387                self.eval_bridge(&code, params.webview_label.as_deref())
2388                    .await
2389            }
2390            LogsAction::SlowIpc => {
2391                let Some(threshold) = params.threshold_ms else {
2392                    return missing_param("threshold_ms", "slow_ipc");
2393                };
2394                let limit = params.limit.unwrap_or(20);
2395                let mb = MAX_LOG_FIELD_BYTES;
2396                let code = format!(
2397                    r"return (function() {{
2398                        var MB = {mb};
2399                        function trimField(v) {{
2400                            if (typeof v === 'string') return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
2401                            if (v && typeof v === 'object') {{ var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }} if (s.length > MB) return '[truncated ' + s.length + ' bytes]'; }}
2402                            return v;
2403                        }}
2404                        function trimEntry(e) {{ if (e == null || typeof e !== 'object') return e; var o = {{}}; for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) o[k] = trimField(e[k]); }} return o; }}
2405                        var log = window.__VICTAURI__?.getIpcLog() || [];
2406                        var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold}; }});
2407                        slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
2408                        return {{ threshold_ms: {threshold}, count: Math.min(slow.length, {limit}), calls: slow.slice(0, {limit}).map(trimEntry) }};
2409                    }})()",
2410                );
2411                self.eval_bridge(&code, None).await
2412            }
2413            LogsAction::Clear => {
2414                let code = "return (function(){ var b = window.__VICTAURI__; if (!b) return { ok:false, error:'bridge unavailable' }; if (b.clearIpcLog) b.clearIpcLog(); if (b.clearNetworkLog) b.clearNetworkLog(); return { ok:true, cleared:['ipc','network'] }; })()";
2415                self.eval_bridge(code, params.webview_label.as_deref())
2416                    .await
2417            }
2418        }
2419    }
2420
2421    // ── Backend Introspection ────────────────────────────────────────────────
2422
2423    #[tool(
2424        description = "Deep backend introspection — command profiling, IPC contract testing, \
2425            coverage, startup timing, capability auditing, database diagnostics, process \
2426            enumeration, and event bus monitoring. \
2427            These features exploit Victauri's position inside the Rust process.\n\n\
2428            Actions:\n\
2429            - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n\
2430            - `coverage`: Which registered commands have been called during this session.\n\
2431            - `contract_record`: Record a command's response shape as a baseline (requires `command`).\n\
2432            - `contract_check`: Check all recorded contracts for schema drift.\n\
2433            - `contract_list`: List all recorded contract baselines.\n\
2434            - `contract_clear`: Clear all recorded contract baselines.\n\
2435            - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
2436            - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
2437            - `db_health`: SQLite database diagnostics (journal mode, WAL, page stats).\n\
2438            - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
2439            - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
2440            - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
2441            - `event_bus`: List all captured Tauri events (automatically intercepted via listen_any — no app opt-in needed).\n\
2442            - `event_bus_clear`: Clear the event bus capture buffer.",
2443        annotations(
2444            read_only_hint = true,
2445            destructive_hint = false,
2446            idempotent_hint = true,
2447            open_world_hint = false
2448        )
2449    )]
2450    async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
2451        self.track_tool_call();
2452        if !self.state.privacy.is_tool_enabled("introspect") {
2453            return tool_disabled("introspect");
2454        }
2455
2456        match params.action {
2457            IntrospectAction::CommandTimings => {
2458                let mut stats = self.state.command_timings.all_stats();
2459                if let Some(threshold) = params.slow_threshold_ms {
2460                    stats.retain(|s| s.avg_ms >= threshold);
2461                }
2462                let result = serde_json::json!({
2463                    "commands": stats,
2464                    "total_commands_profiled": self.state.command_timings.all_stats().len(),
2465                    "slow_threshold_ms": params.slow_threshold_ms,
2466                });
2467                json_result(&result)
2468            }
2469            IntrospectAction::Coverage => {
2470                let registered: Vec<String> = self
2471                    .state
2472                    .registry
2473                    .list()
2474                    .iter()
2475                    .map(|c| c.name.clone())
2476                    .collect();
2477
2478                let code = "return window.__VICTAURI__?.getIpcLog()";
2479                let invoked: std::collections::HashSet<String> = match self
2480                    .eval_with_return(code, params.webview_label.as_deref())
2481                    .await
2482                {
2483                    Ok(json_str) => {
2484                        if let Ok(entries) =
2485                            serde_json::from_str::<Vec<serde_json::Value>>(&json_str)
2486                        {
2487                            entries
2488                                .iter()
2489                                .filter_map(|e| e.get("command").and_then(|c| c.as_str()))
2490                                .map(String::from)
2491                                .collect()
2492                        } else {
2493                            std::collections::HashSet::new()
2494                        }
2495                    }
2496                    Err(_) => std::collections::HashSet::new(),
2497                };
2498
2499                let uncovered: Vec<&String> = registered
2500                    .iter()
2501                    .filter(|cmd| !invoked.contains(cmd.as_str()))
2502                    .collect();
2503
2504                let coverage_pct = if registered.is_empty() {
2505                    100.0
2506                } else {
2507                    let covered = registered.len() - uncovered.len();
2508                    (covered as f64 / registered.len() as f64) * 100.0
2509                };
2510
2511                let result = serde_json::json!({
2512                    "registered_commands": registered.len(),
2513                    "invoked_commands": invoked.len(),
2514                    "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
2515                    "uncovered": uncovered,
2516                    "invoked_not_registered": invoked.iter()
2517                        .filter(|cmd| !registered.contains(cmd))
2518                        .collect::<Vec<_>>(),
2519                });
2520                json_result(&result)
2521            }
2522            IntrospectAction::ContractRecord => {
2523                let Some(command) = params.command else {
2524                    return missing_param("command", "contract_record");
2525                };
2526                let args_json = params.args.unwrap_or(serde_json::json!({}));
2527                let args_str =
2528                    serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
2529                let code = format!(
2530                    "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2531                    js_string(&command)
2532                );
2533                match self
2534                    .eval_with_return(&code, params.webview_label.as_deref())
2535                    .await
2536                {
2537                    Ok(result_str) => {
2538                        let value: serde_json::Value = serde_json::from_str(&result_str)
2539                            .unwrap_or(serde_json::Value::String(result_str.clone()));
2540                        let shape = crate::introspection::JsonShape::from_value(&value);
2541                        let sample = if result_str.len() > 4096 {
2542                            format!("{}...(truncated)", &result_str[..4096])
2543                        } else {
2544                            result_str
2545                        };
2546                        let baseline = crate::introspection::ContractBaseline {
2547                            command: command.clone(),
2548                            args: args_json,
2549                            shape: shape.clone(),
2550                            sample,
2551                            recorded_at: chrono_now(),
2552                        };
2553                        self.state.contract_store.record(baseline);
2554                        let result = serde_json::json!({
2555                            "recorded": true,
2556                            "command": command,
2557                            "shape_type": shape.type_name(),
2558                        });
2559                        json_result(&result)
2560                    }
2561                    Err(e) => tool_error(format!(
2562                        "failed to invoke '{command}' for contract recording: {e}"
2563                    )),
2564                }
2565            }
2566            IntrospectAction::ContractCheck => {
2567                let baselines = self.state.contract_store.all();
2568                if baselines.is_empty() {
2569                    return json_result(&serde_json::json!({
2570                        "checked": 0,
2571                        "message": "no contract baselines recorded — use contract_record first",
2572                    }));
2573                }
2574                let mut results = Vec::new();
2575                for baseline in &baselines {
2576                    let args_str =
2577                        serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
2578                    let code = format!(
2579                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2580                        js_string(&baseline.command)
2581                    );
2582                    match self
2583                        .eval_with_return(&code, params.webview_label.as_deref())
2584                        .await
2585                    {
2586                        Ok(result_str) => {
2587                            let value: serde_json::Value = serde_json::from_str(&result_str)
2588                                .unwrap_or(serde_json::Value::String(result_str));
2589                            let current_shape = crate::introspection::JsonShape::from_value(&value);
2590                            let drift = crate::introspection::diff_shapes(
2591                                &baseline.shape,
2592                                &current_shape,
2593                                &baseline.command,
2594                            );
2595                            results.push(drift);
2596                        }
2597                        Err(e) => {
2598                            results.push(crate::introspection::ContractDrift {
2599                                command: baseline.command.clone(),
2600                                new_fields: Vec::new(),
2601                                removed_fields: Vec::new(),
2602                                type_changes: Vec::new(),
2603                                shape_matches: false,
2604                            });
2605                            tracing::warn!(
2606                                command = %baseline.command,
2607                                error = %e,
2608                                "contract check invocation failed"
2609                            );
2610                        }
2611                    }
2612                }
2613                let passing = results.iter().filter(|r| r.shape_matches).count();
2614                let result = serde_json::json!({
2615                    "checked": results.len(),
2616                    "passing": passing,
2617                    "failing": results.len() - passing,
2618                    "contracts": results,
2619                });
2620                json_result(&result)
2621            }
2622            IntrospectAction::ContractList => {
2623                let baselines = self.state.contract_store.all();
2624                let result = serde_json::json!({
2625                    "count": baselines.len(),
2626                    "baselines": baselines.iter().map(|b| serde_json::json!({
2627                        "command": b.command,
2628                        "shape_type": b.shape.type_name(),
2629                        "recorded_at": b.recorded_at,
2630                    })).collect::<Vec<_>>(),
2631                });
2632                json_result(&result)
2633            }
2634            IntrospectAction::ContractClear => {
2635                let cleared = self.state.contract_store.clear();
2636                json_result(&serde_json::json!({
2637                    "cleared": cleared,
2638                }))
2639            }
2640            IntrospectAction::StartupTiming => {
2641                let phases = self.state.startup_timeline.report();
2642                let result = serde_json::json!({
2643                    "phases": phases,
2644                    "total_ms": self.state.startup_timeline.total_ms(),
2645                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
2646                });
2647                json_result(&result)
2648            }
2649            IntrospectAction::Capabilities => {
2650                let config = self.bridge.tauri_config();
2651                let live_windows = self.bridge.list_window_labels();
2652
2653                let result = serde_json::json!({
2654                    "app": {
2655                        "identifier": config.get("identifier"),
2656                        "product_name": config.get("product_name"),
2657                        "version": config.get("version"),
2658                    },
2659                    "security": config.get("security"),
2660                    "configured_windows": config.get("windows"),
2661                    "live_windows": live_windows,
2662                    "configured_plugins": config.get("plugins"),
2663                    "victauri": {
2664                        "registered_commands": self.state.registry.list().len(),
2665                        "auth_enabled": self.state.privacy.redaction_enabled,
2666                        "privacy_profile": format!("{:?}", self.state.privacy.profile),
2667                        "disabled_tools": &self.state.privacy.disabled_tools,
2668                    },
2669                });
2670                json_result(&result)
2671            }
2672            #[allow(unused_variables)]
2673            IntrospectAction::DbHealth => {
2674                #[cfg(feature = "sqlite")]
2675                {
2676                    let db_path = params.db_path.clone();
2677                    match self.run_db_health(db_path.as_deref()).await {
2678                        Ok(health) => json_result(&health),
2679                        Err(e) => tool_error(format!("db_health failed: {e}")),
2680                    }
2681                }
2682                #[cfg(not(feature = "sqlite"))]
2683                {
2684                    tool_error("SQLite support not compiled in — enable the `sqlite` feature")
2685                }
2686            }
2687            IntrospectAction::PluginState => {
2688                let recording_active = self.state.recorder.is_recording();
2689                let recording_events = self.state.recorder.event_count();
2690                let result = serde_json::json!({
2691                    "event_log": {
2692                        "size": self.state.event_log.len(),
2693                        "capacity": self.state.event_log.capacity(),
2694                    },
2695                    "registry": {
2696                        "commands_registered": self.state.registry.list().len(),
2697                    },
2698                    "recording": {
2699                        "active": recording_active,
2700                        "events_captured": recording_events,
2701                    },
2702                    "faults": {
2703                        "active_rules": self.state.fault_registry.list().len(),
2704                    },
2705                    "contracts": {
2706                        "baselines_recorded": self.state.contract_store.all().len(),
2707                    },
2708                    "timings": {
2709                        "commands_profiled": self.state.command_timings.all_stats().len(),
2710                    },
2711                    "event_bus": {
2712                        "captured_events": self.state.event_bus.len(),
2713                    },
2714                    "tasks": {
2715                        "total": self.state.task_tracker.list().len(),
2716                        "active": self.state.task_tracker.active_count(),
2717                    },
2718                    "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
2719                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
2720                    "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
2721                });
2722                json_result(&result)
2723            }
2724            IntrospectAction::Processes => {
2725                let pid = std::process::id();
2726                let uptime = self.state.started_at.elapsed();
2727                let children = crate::introspection::enumerate_child_processes();
2728                let host_memory = crate::memory::current_stats();
2729
2730                let result = serde_json::json!({
2731                    "host": {
2732                        "pid": pid,
2733                        "uptime_secs": uptime.as_secs(),
2734                        "platform": std::env::consts::OS,
2735                        "arch": std::env::consts::ARCH,
2736                        "memory": host_memory,
2737                    },
2738                    "children": children.iter().map(|c| serde_json::json!({
2739                        "pid": c.pid,
2740                        "name": c.name,
2741                        "memory_bytes": c.memory_bytes,
2742                    })).collect::<Vec<_>>(),
2743                    "child_count": children.len(),
2744                    "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
2745                });
2746                json_result(&result)
2747            }
2748            IntrospectAction::PluginTasks => {
2749                let tasks = self.state.task_tracker.list();
2750                let active = self.state.task_tracker.active_count();
2751                let result = serde_json::json!({
2752                    "total": tasks.len(),
2753                    "active": active,
2754                    "finished": tasks.len() - active,
2755                    "tasks": tasks,
2756                });
2757                json_result(&result)
2758            }
2759            IntrospectAction::EventBus => {
2760                let tauri_events = self.state.event_bus.events();
2761                let app_events = self.state.event_log.snapshot();
2762                let result = serde_json::json!({
2763                    "tauri_events": {
2764                        "count": tauri_events.len(),
2765                        "events": tauri_events,
2766                    },
2767                    "app_events": {
2768                        "count": app_events.len(),
2769                        "capacity": self.state.event_log.capacity(),
2770                        "events": app_events,
2771                    },
2772                });
2773                json_result(&result)
2774            }
2775            IntrospectAction::EventBusClear => {
2776                let tauri_cleared = self.state.event_bus.clear();
2777                self.state.event_log.clear();
2778                json_result(&serde_json::json!({
2779                    "tauri_events_cleared": tauri_cleared,
2780                    "app_events_cleared": true,
2781                }))
2782            }
2783        }
2784    }
2785
2786    // ── Fault Injection / Chaos Engineering ──────────────────────────────────
2787
2788    #[tool(
2789        description = "Probe a backend command handler under failure by faulting it for chaos engineering. \
2790            Simulate slow commands, backend errors, dropped responses, and corrupted data. \
2791            SCOPE: faults apply ONLY to commands you run via this server's `invoke_command` tool — \
2792            they do NOT intercept the app's real user-driven IPC (window.__TAURI_INTERNALS__.invoke), \
2793            which runs below the layer Victauri can reach. Use this to test a handler's error path when \
2794            YOU drive it; it does not reproduce a failure a user clicking the UI would see.\n\n\
2795            Actions:\n\
2796            - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`.\n\
2797            - `list`: List all active fault injection rules.\n\
2798            - `clear`: Remove a specific fault rule (requires `command`).\n\
2799            - `clear_all`: Remove all fault rules.",
2800        annotations(
2801            read_only_hint = false,
2802            destructive_hint = true,
2803            idempotent_hint = false,
2804            open_world_hint = false
2805        )
2806    )]
2807    async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
2808        self.track_tool_call();
2809        if !self.state.privacy.is_tool_enabled("fault") {
2810            return tool_disabled("fault");
2811        }
2812
2813        match params.action {
2814            FaultAction::Inject => {
2815                let Some(command) = params.command else {
2816                    return missing_param("command", "inject");
2817                };
2818                let Some(fault_kind) = params.fault_type else {
2819                    return missing_param("fault_type", "inject");
2820                };
2821                let fault_type = match fault_kind {
2822                    FaultKind::Delay => {
2823                        let delay_ms = params.delay_ms.unwrap_or(1000);
2824                        crate::introspection::FaultType::Delay { delay_ms }
2825                    }
2826                    FaultKind::Error => {
2827                        let message = params
2828                            .error_message
2829                            .unwrap_or_else(|| "injected fault".to_string());
2830                        crate::introspection::FaultType::Error { message }
2831                    }
2832                    FaultKind::Drop => crate::introspection::FaultType::Drop,
2833                    FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
2834                };
2835                let config = crate::introspection::FaultConfig {
2836                    command: command.clone(),
2837                    fault_type: fault_type.clone(),
2838                    trigger_count: 0,
2839                    max_triggers: params.max_triggers.unwrap_or(0),
2840                    created_at: std::time::Instant::now(),
2841                };
2842                self.state.fault_registry.inject(config);
2843                let result = serde_json::json!({
2844                    "injected": true,
2845                    "command": command,
2846                    "fault_type": fault_type,
2847                    "max_triggers": params.max_triggers.unwrap_or(0),
2848                });
2849                json_result(&result)
2850            }
2851            FaultAction::List => {
2852                let faults = self.state.fault_registry.list();
2853                let result = serde_json::json!({
2854                    "count": faults.len(),
2855                    "faults": faults.iter().map(|f| serde_json::json!({
2856                        "command": f.command,
2857                        "fault_type": f.fault_type,
2858                        "trigger_count": f.trigger_count,
2859                        "max_triggers": f.max_triggers,
2860                    })).collect::<Vec<_>>(),
2861                });
2862                json_result(&result)
2863            }
2864            FaultAction::Clear => {
2865                let Some(command) = params.command else {
2866                    return missing_param("command", "clear");
2867                };
2868                let removed = self.state.fault_registry.clear(&command);
2869                json_result(&serde_json::json!({
2870                    "removed": removed,
2871                    "command": command,
2872                }))
2873            }
2874            FaultAction::ClearAll => {
2875                let removed = self.state.fault_registry.clear_all();
2876                json_result(&serde_json::json!({
2877                    "removed": removed,
2878                }))
2879            }
2880        }
2881    }
2882
2883    // ── Cross-Layer Explanation ────────────────────────────────────────────
2884
2885    #[tool(
2886        description = "Correlate recent activity across all layers into a coherent narrative. \
2887            CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
2888            + window events across the Rust backend and webview simultaneously.\n\n\
2889            Actions:\n\
2890            - `summary`: High-level activity summary for the last N seconds (default 30). \
2891              Counts IPC calls, DOM mutations, console entries, network requests, errors.\n\
2892            - `last_action`: Correlate the most recent burst of events into a causal timeline \
2893              (e.g. 'IPC call → DOM update → console.log').\n\
2894            - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
2895        annotations(
2896            read_only_hint = true,
2897            destructive_hint = false,
2898            idempotent_hint = true,
2899            open_world_hint = false
2900        )
2901    )]
2902    async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
2903        self.track_tool_call();
2904        if !self.state.privacy.is_tool_enabled("explain") {
2905            return tool_disabled("explain");
2906        }
2907
2908        match params.action {
2909            ExplainAction::Summary => {
2910                let secs = params.seconds.unwrap_or(30);
2911                let since = chrono::Utc::now()
2912                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2913                let events = self.state.event_log.since(since);
2914
2915                let mut ipc_count = 0u64;
2916                let mut dom_mutations = 0u64;
2917                let mut state_changes = 0u64;
2918                let mut console_count = 0u64;
2919                let mut window_events = 0u64;
2920                let mut interactions = 0u64;
2921                let mut top_commands: HashMap<String, u64> = HashMap::new();
2922                let mut errors: Vec<String> = Vec::new();
2923
2924                for event in &events {
2925                    match event {
2926                        victauri_core::AppEvent::Ipc(call) => {
2927                            ipc_count += 1;
2928                            *top_commands.entry(call.command.clone()).or_insert(0) += 1;
2929                            if let victauri_core::IpcResult::Err(e) = &call.result {
2930                                errors.push(format!("IPC {}: {e}", call.command));
2931                            }
2932                        }
2933                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
2934                            dom_mutations += u64::from(*mutation_count)
2935                        }
2936                        victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
2937                        victauri_core::AppEvent::Console { level, message, .. } => {
2938                            console_count += 1;
2939                            if level == "error" {
2940                                errors.push(format!("console.error: {message}"));
2941                            }
2942                        }
2943                        victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
2944                        victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
2945                        _ => {}
2946                    }
2947                }
2948
2949                let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
2950                sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
2951                let top: Vec<_> = sorted_cmds.iter().take(5).collect();
2952
2953                let narrative = format!(
2954                    "{ipc_count} IPC call{} in the last {secs}s{}. \
2955                     {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
2956                     {console_count} console message{}, {window_events} window event{}. {}.",
2957                    if ipc_count == 1 { "" } else { "s" },
2958                    if top.is_empty() {
2959                        String::new()
2960                    } else {
2961                        format!(
2962                            ", dominated by {}",
2963                            top.iter()
2964                                .map(|(cmd, n)| format!("{cmd} ({n}x)"))
2965                                .collect::<Vec<_>>()
2966                                .join(", ")
2967                        )
2968                    },
2969                    if dom_mutations == 1 { "" } else { "s" },
2970                    if interactions == 1 { "" } else { "s" },
2971                    if console_count == 1 { "" } else { "s" },
2972                    if window_events == 1 { "" } else { "s" },
2973                    if errors.is_empty() {
2974                        "No errors".to_string()
2975                    } else {
2976                        format!(
2977                            "{} error{}",
2978                            errors.len(),
2979                            if errors.len() == 1 { "" } else { "s" }
2980                        )
2981                    },
2982                );
2983
2984                let result = serde_json::json!({
2985                    "time_window_secs": secs,
2986                    "total_events": events.len(),
2987                    "ipc_calls": ipc_count,
2988                    "dom_mutations": dom_mutations,
2989                    "state_changes": state_changes,
2990                    "console_messages": console_count,
2991                    "window_events": window_events,
2992                    "interactions": interactions,
2993                    "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
2994                        serde_json::json!({"command": cmd, "count": n})
2995                    }).collect::<Vec<_>>(),
2996                    "errors": errors,
2997                    "narrative": narrative,
2998                });
2999                json_result(&result)
3000            }
3001            ExplainAction::LastAction => {
3002                let secs = params.seconds.unwrap_or(5);
3003                let since = chrono::Utc::now()
3004                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3005                let events = self.state.event_log.since(since);
3006
3007                let timeline: Vec<serde_json::Value> = events
3008                    .iter()
3009                    .filter(|e| !e.is_internal())
3010                    .map(|event| match event {
3011                        victauri_core::AppEvent::Ipc(call) => serde_json::json!({
3012                            "time": call.timestamp.to_rfc3339_opts(
3013                                chrono::SecondsFormat::Millis, true
3014                            ),
3015                            "type": "ipc",
3016                            "detail": format!(
3017                                "{} {} ({}ms)",
3018                                call.command,
3019                                call.result,
3020                                call.duration_ms.unwrap_or(0)
3021                            ),
3022                        }),
3023                        victauri_core::AppEvent::DomMutation {
3024                            timestamp,
3025                            mutation_count,
3026                            webview_label,
3027                        } => serde_json::json!({
3028                            "time": timestamp.to_rfc3339_opts(
3029                                chrono::SecondsFormat::Millis, true
3030                            ),
3031                            "type": "dom_mutation",
3032                            "detail": format!(
3033                                "{mutation_count} element{} updated in {webview_label}",
3034                                if *mutation_count == 1 { "" } else { "s" }
3035                            ),
3036                        }),
3037                        victauri_core::AppEvent::DomInteraction {
3038                            timestamp,
3039                            action,
3040                            selector,
3041                            ..
3042                        } => serde_json::json!({
3043                            "time": timestamp.to_rfc3339_opts(
3044                                chrono::SecondsFormat::Millis, true
3045                            ),
3046                            "type": "interaction",
3047                            "detail": format!("{action} on {selector}"),
3048                        }),
3049                        victauri_core::AppEvent::StateChange {
3050                            timestamp,
3051                            key,
3052                            caused_by,
3053                        } => serde_json::json!({
3054                            "time": timestamp.to_rfc3339_opts(
3055                                chrono::SecondsFormat::Millis, true
3056                            ),
3057                            "type": "state_change",
3058                            "detail": format!(
3059                                "{key} changed{}",
3060                                caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
3061                            ),
3062                        }),
3063                        victauri_core::AppEvent::Console {
3064                            timestamp,
3065                            level,
3066                            message,
3067                        } => serde_json::json!({
3068                            "time": timestamp.to_rfc3339_opts(
3069                                chrono::SecondsFormat::Millis, true
3070                            ),
3071                            "type": "console",
3072                            "detail": format!("console.{level}: {message}"),
3073                        }),
3074                        victauri_core::AppEvent::WindowEvent {
3075                            timestamp,
3076                            label,
3077                            event,
3078                        } => serde_json::json!({
3079                            "time": timestamp.to_rfc3339_opts(
3080                                chrono::SecondsFormat::Millis, true
3081                            ),
3082                            "type": "window_event",
3083                            "detail": format!("{event} on window '{label}'"),
3084                        }),
3085                        _ => serde_json::json!({
3086                            "time": event.timestamp().to_rfc3339_opts(
3087                                chrono::SecondsFormat::Millis, true
3088                            ),
3089                            "type": "other",
3090                            "detail": "unknown event type",
3091                        }),
3092                    })
3093                    .collect();
3094
3095                let narrative = if timeline.is_empty() {
3096                    format!("No activity in the last {secs}s.")
3097                } else {
3098                    let parts: Vec<String> = timeline
3099                        .iter()
3100                        .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
3101                        .map(String::from)
3102                        .collect();
3103                    parts.join(" → ")
3104                };
3105
3106                let result = serde_json::json!({
3107                    "time_window_secs": secs,
3108                    "event_count": timeline.len(),
3109                    "timeline": timeline,
3110                    "narrative": narrative,
3111                });
3112                json_result(&result)
3113            }
3114            ExplainAction::Diff => {
3115                let secs = params.seconds.unwrap_or(10);
3116                let since = chrono::Utc::now()
3117                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3118                let events = self.state.event_log.since(since);
3119
3120                let mut ipc_commands: Vec<String> = Vec::new();
3121                let mut dom_changes = 0u64;
3122                let mut error_count = 0u64;
3123                let mut interaction_count = 0u64;
3124                let mut console_messages = 0u64;
3125
3126                for event in &events {
3127                    if event.is_internal() {
3128                        continue;
3129                    }
3130                    match event {
3131                        victauri_core::AppEvent::Ipc(call) => {
3132                            ipc_commands.push(call.command.clone());
3133                            if matches!(call.result, victauri_core::IpcResult::Err(_)) {
3134                                error_count += 1;
3135                            }
3136                        }
3137                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
3138                            dom_changes += u64::from(*mutation_count)
3139                        }
3140                        victauri_core::AppEvent::DomInteraction { .. } => {
3141                            interaction_count += 1;
3142                        }
3143                        victauri_core::AppEvent::Console { level, .. } => {
3144                            console_messages += 1;
3145                            if level == "error" {
3146                                error_count += 1;
3147                            }
3148                        }
3149                        _ => {}
3150                    }
3151                }
3152
3153                ipc_commands.dedup();
3154
3155                let result = serde_json::json!({
3156                    "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
3157                    "time_window_secs": secs,
3158                    "total_events": events.len(),
3159                    "ipc_calls_made": ipc_commands.len(),
3160                    "unique_commands": ipc_commands,
3161                    "dom_elements_changed": dom_changes,
3162                    "interactions": interaction_count,
3163                    "console_messages": console_messages,
3164                    "errors": error_count,
3165                });
3166                json_result(&result)
3167            }
3168        }
3169    }
3170}
3171
3172impl VictauriMcpHandler {
3173    /// Create a new handler backed by the given state and webview bridge.
3174    pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
3175        Self {
3176            state,
3177            bridge,
3178            subscriptions: Arc::new(Mutex::new(HashSet::new())),
3179            bridge_checked: Arc::new(AtomicBool::new(false)),
3180            probed_labels: Arc::new(Mutex::new(HashSet::new())),
3181            timed_out_labels: Arc::new(Mutex::new(HashSet::new())),
3182        }
3183    }
3184
3185    pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
3186        self.state.privacy.is_tool_enabled(name)
3187    }
3188
3189    pub(crate) async fn execute_tool(
3190        &self,
3191        name: &str,
3192        args: serde_json::Value,
3193    ) -> Result<CallToolResult, rest::ToolCallError> {
3194        if !self.state.privacy.is_tool_enabled(name) {
3195            return Ok(tool_disabled(name));
3196        }
3197        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
3198        let start = std::time::Instant::now();
3199        tracing::debug!(tool = %name, "REST tool invocation started");
3200
3201        let result = match name {
3202            "eval_js" => {
3203                let p: EvalJsParams = Self::parse_args(args)?;
3204                self.eval_js(Parameters(p)).await
3205            }
3206            "dom_snapshot" => {
3207                let p: SnapshotParams = Self::parse_args(args)?;
3208                self.dom_snapshot(Parameters(p)).await
3209            }
3210            "find_elements" => {
3211                let p: FindElementsParams = Self::parse_args(args)?;
3212                self.find_elements(Parameters(p)).await
3213            }
3214            "invoke_command" => {
3215                let p: InvokeCommandParams = Self::parse_args(args)?;
3216                self.invoke_command(Parameters(p)).await
3217            }
3218            "screenshot" => {
3219                let p: ScreenshotParams = Self::parse_args(args)?;
3220                self.screenshot(Parameters(p)).await
3221            }
3222            "verify_state" => {
3223                let p: VerifyStateParams = Self::parse_args(args)?;
3224                self.verify_state(Parameters(p)).await
3225            }
3226            "detect_ghost_commands" => {
3227                let p: GhostCommandParams = Self::parse_args(args)?;
3228                self.detect_ghost_commands(Parameters(p)).await
3229            }
3230            "check_ipc_integrity" => {
3231                let p: IpcIntegrityParams = Self::parse_args(args)?;
3232                self.check_ipc_integrity(Parameters(p)).await
3233            }
3234            "wait_for" => {
3235                let p: WaitForParams = Self::parse_args(args)?;
3236                self.wait_for(Parameters(p)).await
3237            }
3238            "assert_semantic" => {
3239                let p: SemanticAssertParams = Self::parse_args(args)?;
3240                self.assert_semantic(Parameters(p)).await
3241            }
3242            "resolve_command" => {
3243                let p: ResolveCommandParams = Self::parse_args(args)?;
3244                self.resolve_command(Parameters(p)).await
3245            }
3246            "get_registry" => {
3247                let p: RegistryParams = Self::parse_args(args)?;
3248                self.get_registry(Parameters(p)).await
3249            }
3250            "get_memory_stats" => self.get_memory_stats().await,
3251            "get_plugin_info" => self.get_plugin_info().await,
3252            "get_diagnostics" => {
3253                let p: DiagnosticsParams = Self::parse_args(args)?;
3254                self.get_diagnostics(Parameters(p)).await
3255            }
3256            "app_info" => self.app_info().await,
3257            "list_app_dir" => {
3258                let p: ListAppDirParams = Self::parse_args(args)?;
3259                self.list_app_dir(Parameters(p)).await
3260            }
3261            "read_app_file" => {
3262                let p: ReadAppFileParams = Self::parse_args(args)?;
3263                self.read_app_file(Parameters(p)).await
3264            }
3265            #[cfg(feature = "sqlite")]
3266            "query_db" => {
3267                let p: QueryDbParams = Self::parse_args(args)?;
3268                self.query_db(Parameters(p)).await
3269            }
3270            "interact" => {
3271                let p: InteractParams = Self::parse_args(args)?;
3272                self.interact(Parameters(p)).await
3273            }
3274            "input" => {
3275                let p: InputParams = Self::parse_args(args)?;
3276                self.input(Parameters(p)).await
3277            }
3278            "window" => {
3279                let p: WindowParams = Self::parse_args(args)?;
3280                self.window(Parameters(p)).await
3281            }
3282            "storage" => {
3283                let p: StorageParams = Self::parse_args(args)?;
3284                self.storage(Parameters(p)).await
3285            }
3286            "navigate" => {
3287                let p: NavigateParams = Self::parse_args(args)?;
3288                self.navigate(Parameters(p)).await
3289            }
3290            "recording" => {
3291                let p: RecordingParams = Self::parse_args(args)?;
3292                self.recording(Parameters(p)).await
3293            }
3294            "inspect" => {
3295                let p: InspectParams = Self::parse_args(args)?;
3296                self.inspect(Parameters(p)).await
3297            }
3298            "css" => {
3299                let p: CssParams = Self::parse_args(args)?;
3300                self.css(Parameters(p)).await
3301            }
3302            "route" => {
3303                let p: RouteParams = Self::parse_args(args)?;
3304                self.route(Parameters(p)).await
3305            }
3306            "trace" => {
3307                let p: TraceParams = Self::parse_args(args)?;
3308                self.trace(Parameters(p)).await
3309            }
3310            "animation" => {
3311                let p: AnimationParams = Self::parse_args(args)?;
3312                self.animation(Parameters(p)).await
3313            }
3314            "logs" => {
3315                let p: LogsParams = Self::parse_args(args)?;
3316                self.logs(Parameters(p)).await
3317            }
3318            "introspect" => {
3319                let p: IntrospectParams = Self::parse_args(args)?;
3320                self.introspect(Parameters(p)).await
3321            }
3322            "fault" => {
3323                let p: FaultParams = Self::parse_args(args)?;
3324                self.fault(Parameters(p)).await
3325            }
3326            "explain" => {
3327                let p: ExplainParams = Self::parse_args(args)?;
3328                self.explain(Parameters(p)).await
3329            }
3330            _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
3331        };
3332
3333        let elapsed = start.elapsed();
3334        tracing::debug!(
3335            tool = %name,
3336            elapsed_ms = elapsed.as_millis() as u64,
3337            "REST tool invocation completed"
3338        );
3339
3340        if self.state.privacy.redaction_enabled {
3341            Ok(Self::redact_result(result, &self.state.privacy))
3342        } else {
3343            Ok(result)
3344        }
3345    }
3346
3347    fn parse_args<T: serde::de::DeserializeOwned>(
3348        args: serde_json::Value,
3349    ) -> Result<T, rest::ToolCallError> {
3350        serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
3351    }
3352
3353    fn redact_result(
3354        mut result: CallToolResult,
3355        privacy: &crate::privacy::PrivacyConfig,
3356    ) -> CallToolResult {
3357        for item in &mut result.content {
3358            if let RawContent::Text(ref mut tc) = item.raw {
3359                tc.text = privacy.redact_output(&tc.text);
3360            }
3361        }
3362        result
3363    }
3364
3365    fn track_tool_call(&self) {
3366        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
3367    }
3368
3369    fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
3370        match dir.unwrap_or(AppDir::Data) {
3371            AppDir::Data => self.bridge.app_data_dir(),
3372            AppDir::Config => self.bridge.app_config_dir(),
3373            AppDir::Log => self.bridge.app_log_dir(),
3374            AppDir::LocalData => self.bridge.app_local_data_dir(),
3375        }
3376    }
3377
3378    fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
3379        let canon_base = std::fs::canonicalize(base)
3380            .map_err(|e| format!("cannot resolve base directory: {e}"))?;
3381        let canon_target = std::fs::canonicalize(target)
3382            .map_err(|e| format!("cannot resolve target path: {e}"))?;
3383        if !canon_target.starts_with(&canon_base) {
3384            return Err("path traversal not allowed".to_string());
3385        }
3386        Ok(())
3387    }
3388
3389    fn list_dir_recursive(
3390        dir: &std::path::Path,
3391        base: &std::path::Path,
3392        depth: u32,
3393        max_depth: u32,
3394        pattern: Option<&str>,
3395        entries: &mut Vec<serde_json::Value>,
3396    ) {
3397        let Ok(read_dir) = std::fs::read_dir(dir) else {
3398            return;
3399        };
3400        for entry in read_dir.flatten() {
3401            let path = entry.path();
3402            if path.is_symlink() {
3403                continue;
3404            }
3405            let name = entry.file_name().to_string_lossy().into_owned();
3406            let relative = path
3407                .strip_prefix(base)
3408                .unwrap_or(&path)
3409                .to_string_lossy()
3410                .into_owned();
3411
3412            if let Some(pat) = pattern
3413                && !Self::matches_glob(&name, pat)
3414                && !path.is_dir()
3415            {
3416                continue;
3417            }
3418
3419            let is_dir = path.is_dir();
3420            let meta = std::fs::metadata(&path).ok();
3421
3422            entries.push(serde_json::json!({
3423                "name": name,
3424                "path": relative,
3425                "is_dir": is_dir,
3426                "size": meta.as_ref().map(std::fs::Metadata::len),
3427                "modified": meta.as_ref()
3428                    .and_then(|m| m.modified().ok())
3429                    .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
3430                        .unwrap_or_default().as_secs()),
3431            }));
3432
3433            if is_dir && depth < max_depth {
3434                Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
3435            }
3436        }
3437    }
3438
3439    fn matches_glob(name: &str, pattern: &str) -> bool {
3440        if pattern == "*" {
3441            return true;
3442        }
3443        if let Some(suffix) = pattern.strip_prefix("*.") {
3444            return name.ends_with(&format!(".{suffix}"));
3445        }
3446        if let Some(prefix) = pattern.strip_suffix("*") {
3447            return name.starts_with(prefix);
3448        }
3449        name == pattern
3450    }
3451
3452    /// Probe every window's JS bridge and report which are introspectable. A
3453    /// visible window that fails to respond almost always lacks the
3454    /// `victauri:default` capability — Tauri's permission ACL silently blocks
3455    /// the bridge's callback IPC, so eval/dom/animation tools see nothing. This
3456    /// turns that silent dead-end into an actionable, up-front diagnosis.
3457    async fn window_introspectability(&self) -> CallToolResult {
3458        let labels = self.bridge.list_window_labels();
3459        let states = self.bridge.get_window_states(None);
3460        let mut report = Vec::with_capacity(labels.len());
3461        let mut blind = 0usize;
3462        for label in &labels {
3463            let visible = states.iter().find(|s| &s.label == label).map(|s| s.visible);
3464            let introspectable = self.probe_bridge(Some(label)).await.is_ok();
3465            if !introspectable {
3466                blind += 1;
3467            }
3468            let note = if introspectable {
3469                "ok — Victauri JS bridge is responding".to_string()
3470            } else if visible == Some(true) {
3471                format!(
3472                    "NOT introspectable although the window is visible — almost certainly missing \
3473                     the Victauri capability. Add \"victauri:default\" to the capability file \
3474                     (src-tauri/capabilities/*.json) whose \"windows\" list includes \"{label}\", \
3475                     then rebuild. Capabilities are baked at compile time, so a rebuild is required."
3476                )
3477            } else {
3478                "NOT introspectable (window is hidden and/or has no bridge) — show the window to \
3479                 confirm, and ensure its capability includes \"victauri:default\", then rebuild."
3480                    .to_string()
3481            };
3482            report.push(serde_json::json!({
3483                "label": label,
3484                "visible": visible,
3485                "introspectable": introspectable,
3486                "note": note,
3487            }));
3488        }
3489        let hint = if blind > 0 {
3490            "Windows with introspectable:false have no working Victauri JS bridge — eval_js, \
3491             dom_snapshot, animation, find_elements, etc. cannot see them. The usual cause is a \
3492             missing \"victauri:default\" capability for that window: Tauri's per-window permission \
3493             ACL silently blocks the bridge's callback IPC. This capability is required per window, \
3494             not just for the main window. (Note: probing a blind window takes ~2s each.)"
3495        } else {
3496            "All windows are introspectable."
3497        };
3498        json_result(&serde_json::json!({
3499            "windows": report,
3500            "introspectable_count": labels.len().saturating_sub(blind),
3501            "blind_count": blind,
3502            "hint": hint,
3503        }))
3504    }
3505
3506    async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
3507        match self.eval_with_return(code, webview_label).await {
3508            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
3509            Err(e) => tool_error(e),
3510        }
3511    }
3512
3513    async fn eval_with_return(
3514        &self,
3515        code: &str,
3516        webview_label: Option<&str>,
3517    ) -> Result<String, String> {
3518        self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
3519            .await
3520    }
3521
3522    async fn probe_bridge(&self, webview_label: Option<&str>) -> Result<(), String> {
3523        let id = uuid::Uuid::new_v4().to_string();
3524        let (tx, rx) = tokio::sync::oneshot::channel();
3525        {
3526            let mut pending = self.state.pending_evals.lock().await;
3527            pending.insert(id.clone(), tx);
3528        }
3529        let id_js = js_string(&id);
3530        let probe = format!(
3531            r#"(async()=>{{await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',{{id:{id_js},result:'"probe_ok"'}});}})();"#
3532        );
3533        if let Err(e) = self.bridge.eval_webview(webview_label, &probe) {
3534            self.state.pending_evals.lock().await.remove(&id);
3535            return Err(format!("eval injection failed: {e}"));
3536        }
3537        if let Ok(Ok(_)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await {
3538            Ok(())
3539        } else {
3540            self.state.pending_evals.lock().await.remove(&id);
3541            let label = webview_label.unwrap_or("default");
3542            Err(format!(
3543                "bridge not responding on window '{label}' — the window may be hidden, \
3544                 missing the victauri capability, or the JS bridge is not loaded"
3545            ))
3546        }
3547    }
3548
3549    async fn eval_with_return_timeout(
3550        &self,
3551        code: &str,
3552        webview_label: Option<&str>,
3553        timeout: std::time::Duration,
3554    ) -> Result<String, String> {
3555        self.track_tool_call();
3556
3557        // Wait for the JS bridge ready signal (sent on bridge init) before
3558        // attempting evals.  For explicitly targeted windows the probe
3559        // mechanism is still used because the ready signal only proves that
3560        // *some* webview's bridge loaded — not necessarily the targeted one.
3561        if !self
3562            .state
3563            .bridge_ready
3564            .load(std::sync::atomic::Ordering::Acquire)
3565        {
3566            let notified = self.state.bridge_notify.notified();
3567            if !self
3568                .state
3569                .bridge_ready
3570                .load(std::sync::atomic::Ordering::Acquire)
3571            {
3572                let _ = tokio::time::timeout(std::time::Duration::from_secs(5), notified).await;
3573            }
3574        }
3575
3576        // Reserved sentinel key for the default (unlabeled) window — cannot
3577        // collide with a real label.
3578        let label_key =
3579            webview_label.map_or_else(|| "\u{1}__default__".to_string(), str::to_string);
3580
3581        // Proactively probe explicitly-targeted windows once (cached), so a
3582        // hidden/unready window fails fast rather than after the full timeout.
3583        if webview_label.is_some() {
3584            let already_probed = self.probed_labels.lock().await.contains(&label_key);
3585            if !already_probed {
3586                self.probe_bridge(webview_label).await?;
3587                self.probed_labels.lock().await.insert(label_key.clone());
3588            }
3589        }
3590
3591        // Resilience: if the PREVIOUS eval on this window timed out, the bridge
3592        // may have gone away (the webview reloaded or the app crashed). Do a
3593        // fast liveness probe so this call fails in ~2s with a clear error
3594        // instead of blocking the full timeout again. If the bridge is alive
3595        // (the earlier timeout was slow code / an infinite loop), the probe
3596        // succeeds quickly and we proceed normally.
3597        if self.timed_out_labels.lock().await.remove(&label_key) {
3598            self.probe_bridge(webview_label).await.map_err(|e| {
3599                format!("{e} (previous eval on this window timed out; the webview may have reloaded or the app stopped responding)")
3600            })?;
3601        }
3602
3603        let id = uuid::Uuid::new_v4().to_string();
3604        let (tx, rx) = tokio::sync::oneshot::channel();
3605
3606        {
3607            let mut pending = self.state.pending_evals.lock().await;
3608            if pending.len() >= MAX_PENDING_EVALS {
3609                return Err(format!(
3610                    "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
3611                ));
3612            }
3613            pending.insert(id.clone(), tx);
3614        }
3615
3616        // Auto-prepend `return` so bare expressions produce a value — but ONLY
3617        // for single expressions. Multi-statement blocks (or code containing an
3618        // explicit `return`) are used as-is. Prepending `return` to a statement
3619        // block like `foo(); return bar()` would parse as `return foo();` and
3620        // silently discard everything after the first statement (issue: core
3621        // primitive returned wrong/undefined values for "do X, then return Y").
3622        let code = if should_prepend_return(code) {
3623            format!("return {}", code.trim())
3624        } else {
3625            code.trim().to_string()
3626        };
3627
3628        let id_js = js_string(&id);
3629        let inject = format!(
3630            r"
3631            (async () => {{
3632                try {{
3633                    const __result = await (async () => {{ {code} }})();
3634                    const __type = __result === undefined ? 'undefined'
3635                        : __result === null ? 'null' : 'value';
3636                    const __val = __type === 'undefined' ? null
3637                        : __type === 'null' ? null : __result;
3638                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
3639                        id: {id_js},
3640                        result: JSON.stringify({{ __victauri_ok: __val, __victauri_type: __type }})
3641                    }});
3642                }} catch (e) {{
3643                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
3644                        id: {id_js},
3645                        result: JSON.stringify({{ __victauri_err: String(e && e.message || e) }})
3646                    }});
3647                }}
3648            }})();
3649            "
3650        );
3651
3652        if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
3653            self.state.pending_evals.lock().await.remove(&id);
3654            return Err(format!("eval injection failed: {e}"));
3655        }
3656
3657        match tokio::time::timeout(timeout, rx).await {
3658            Ok(Ok(raw)) => {
3659                self.check_bridge_version_once();
3660                if raw.len() > MAX_EVAL_RESULT_LEN {
3661                    return Err(format!(
3662                        "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
3663                        raw.len()
3664                    ));
3665                }
3666                unwrap_eval_envelope(raw)
3667            }
3668            Ok(Err(_)) => Err("eval callback channel closed".to_string()),
3669            Err(_) => {
3670                self.state.pending_evals.lock().await.remove(&id);
3671                // Mark this window so the NEXT eval does a fast liveness probe —
3672                // if the bridge is gone (reloaded/crashed) the next call fails in
3673                // ~2s instead of blocking the full timeout again.
3674                self.timed_out_labels.lock().await.insert(label_key.clone());
3675                Err(format!(
3676                    "eval timed out after {}s — the code never resolved. Common causes: a \
3677                     JavaScript syntax error in the injected code (parse errors cannot be \
3678                     reported by the webview and surface only as this timeout), an unresolved \
3679                     promise, an infinite loop, or the webview reloaded / the app stopped \
3680                     responding mid-eval. Verify the code parses and resolves; if the app may \
3681                     have navigated or crashed, retry (the next call fails fast if the bridge \
3682                     is gone).",
3683                    timeout.as_secs()
3684                ))
3685            }
3686        }
3687    }
3688
3689    #[cfg(feature = "sqlite")]
3690    async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
3691        // Roots: configured db_search_paths first, then app directories.
3692        let mut roots: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
3693        for d in [
3694            self.bridge.app_data_dir(),
3695            self.bridge.app_local_data_dir(),
3696            self.bridge.app_config_dir(),
3697        ]
3698        .into_iter()
3699        .flatten()
3700        {
3701            roots.push(d);
3702        }
3703
3704        let path = if let Some(p) = db_path {
3705            let candidate = std::path::Path::new(p);
3706            if candidate.is_absolute() {
3707                if !roots
3708                    .iter()
3709                    .any(|r| Self::safe_within(r, candidate).is_ok())
3710                {
3711                    return Err(format!(
3712                        "absolute path '{p}' is not within an allowed directory; \
3713                         register its parent via VictauriBuilder::db_search_paths"
3714                    ));
3715                }
3716                candidate.to_path_buf()
3717            } else {
3718                roots
3719                    .iter()
3720                    .map(|r| r.join(p))
3721                    .find(|c| c.exists())
3722                    .ok_or_else(|| format!("database not found: {p}"))?
3723            }
3724        } else {
3725            roots
3726                .iter()
3727                .flat_map(|r| crate::database::discover_databases(r))
3728                .next()
3729                .ok_or_else(|| {
3730                    "no database found in app directories or configured db_search_paths".to_string()
3731                })?
3732        };
3733        // No further containment check needed: the path is either discovered
3734        // within an allowed root, an existing relative file joined onto an
3735        // allowed root, or an absolute path already verified above. (A
3736        // safe_within check against app_data_dir would fail when that directory
3737        // does not exist — common for apps that store data elsewhere.)
3738        let path_str = path
3739            .to_str()
3740            .ok_or_else(|| "invalid path encoding".to_string())?
3741            .to_string();
3742
3743        tokio::task::spawn_blocking(move || {
3744            let conn = rusqlite::Connection::open_with_flags(
3745                &path_str,
3746                rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
3747            )
3748            .map_err(|e| format!("cannot open database: {e}"))?;
3749
3750            let journal_mode: String = conn
3751                .pragma_query_value(None, "journal_mode", |r| r.get(0))
3752                .unwrap_or_else(|_| "unknown".to_string());
3753
3754            let page_count: i64 = conn
3755                .pragma_query_value(None, "page_count", |r| r.get(0))
3756                .unwrap_or(0);
3757
3758            let page_size: i64 = conn
3759                .pragma_query_value(None, "page_size", |r| r.get(0))
3760                .unwrap_or(0);
3761
3762            let freelist_count: i64 = conn
3763                .pragma_query_value(None, "freelist_count", |r| r.get(0))
3764                .unwrap_or(0);
3765
3766            let wal_checkpoint: String = if journal_mode == "wal" {
3767                let mut info = String::from("n/a");
3768                let _ = conn.pragma_query(None, "wal_checkpoint", |r| {
3769                    let busy: i64 = r.get(0)?;
3770                    let checkpointed: i64 = r.get(1)?;
3771                    let total: i64 = r.get(2)?;
3772                    info = format!("busy={busy}, checkpointed={checkpointed}, total={total}");
3773                    Ok(())
3774                });
3775                info
3776            } else {
3777                "n/a (not WAL mode)".to_string()
3778            };
3779
3780            let integrity: String = conn
3781                .pragma_query_value(None, "quick_check", |r| r.get(0))
3782                .unwrap_or_else(|_| "failed".to_string());
3783
3784            let db_size_bytes = page_count * page_size;
3785            let db_size_mb = db_size_bytes as f64 / (1024.0 * 1024.0);
3786
3787            let mut tables = Vec::new();
3788            if let Ok(mut stmt) =
3789                conn.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
3790                && let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0))
3791            {
3792                for name in rows.flatten() {
3793                    let count: i64 = conn
3794                        .query_row(&format!("SELECT count(*) FROM [{name}]"), [], |r| r.get(0))
3795                        .unwrap_or(0);
3796                    tables.push(serde_json::json!({
3797                        "name": name,
3798                        "row_count": count,
3799                    }));
3800                }
3801            }
3802
3803            Ok(serde_json::json!({
3804                "database": path_str,
3805                "journal_mode": journal_mode,
3806                "page_count": page_count,
3807                "page_size": page_size,
3808                "db_size_mb": (db_size_mb * 100.0).round() / 100.0,
3809                "freelist_count": freelist_count,
3810                "wal_checkpoint": wal_checkpoint,
3811                "integrity_check": integrity,
3812                "tables": tables,
3813            }))
3814        })
3815        .await
3816        .map_err(|e| format!("db health task failed: {e}"))?
3817    }
3818
3819    fn check_bridge_version_once(&self) {
3820        if self.bridge_checked.swap(true, Ordering::Relaxed) {
3821            return;
3822        }
3823        let handler = self.clone();
3824        tokio::spawn(async move {
3825            match handler
3826                .eval_with_return_timeout(
3827                    "window.__VICTAURI__?.version",
3828                    None,
3829                    std::time::Duration::from_secs(5),
3830                )
3831                .await
3832            {
3833                Ok(v) => {
3834                    let v = v.trim_matches('"');
3835                    if v == BRIDGE_VERSION {
3836                        tracing::debug!("Bridge version verified: {v}");
3837                    } else {
3838                        tracing::warn!(
3839                            "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
3840                        );
3841                    }
3842                }
3843                Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
3844            }
3845        });
3846    }
3847}
3848
3849const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
3850It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
3851(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
3852(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
3853\n\nBACKEND tools (direct Rust access, no webview needed): \
3854'app_info' (app config, directory paths, discovered databases, process info), \
3855'list_app_dir' (browse app data/config/log directories), \
3856'read_app_file' (read files from app directories), \
3857'query_db' (read-only SQLite queries with auto-discovery). \
3858\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
3859'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
3860capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
3861Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
3862capability/security auditing, database diagnostics, plugin state, child process enumeration, \
3863task tracking, and automatic Tauri event bus monitoring. \
3864'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
3865drops, and response corruption into Tauri commands at the Rust layer. \
3866'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
3867activity across IPC + DOM + console + network + window events into a coherent narrative. \
3868\n\nWEBVIEW tools: \
3869'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
3870'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
3871'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
3872\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
3873\n\nCOMPOUND tools with an 'action' parameter: \
3874'window' (get_state, list, manage, resize, move_to, set_title), \
3875'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
3876set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
3877get_events, events_between, get_replay, export, import, replay), \
3878'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
3879\n\nOTHER: verify_state, wait_for, assert_semantic, resolve_command, \
3880get_memory_stats, get_plugin_info, get_diagnostics.";
3881
3882impl ServerHandler for VictauriMcpHandler {
3883    fn get_info(&self) -> ServerInfo {
3884        ServerInfo::new(
3885            ServerCapabilities::builder()
3886                .enable_tools()
3887                .enable_resources()
3888                .enable_resources_subscribe()
3889                .build(),
3890        )
3891        .with_instructions(SERVER_INSTRUCTIONS)
3892    }
3893
3894    async fn list_tools(
3895        &self,
3896        _request: Option<PaginatedRequestParams>,
3897        _context: RequestContext<RoleServer>,
3898    ) -> Result<ListToolsResult, ErrorData> {
3899        let all_tools = Self::tool_router().list_all();
3900        let filtered: Vec<Tool> = all_tools
3901            .into_iter()
3902            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
3903            .collect();
3904        Ok(ListToolsResult {
3905            tools: filtered,
3906            ..Default::default()
3907        })
3908    }
3909
3910    async fn call_tool(
3911        &self,
3912        request: CallToolRequestParams,
3913        context: RequestContext<RoleServer>,
3914    ) -> Result<CallToolResult, ErrorData> {
3915        let tool_name: String = request.name.as_ref().to_owned();
3916        if !self.state.privacy.is_tool_enabled(&tool_name) {
3917            tracing::debug!(tool = %tool_name, "tool call blocked by privacy config");
3918            return Ok(tool_disabled(&tool_name));
3919        }
3920        self.state
3921            .tool_invocations
3922            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3923        let start = std::time::Instant::now();
3924        tracing::debug!(tool = %tool_name, "tool invocation started");
3925        let ctx = ToolCallContext::new(self, request, context);
3926        let result = Self::tool_router().call(ctx).await;
3927        let elapsed = start.elapsed();
3928        tracing::debug!(
3929            tool = %tool_name,
3930            elapsed_ms = elapsed.as_millis() as u64,
3931            is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
3932            "tool invocation completed"
3933        );
3934
3935        // Centralized output redaction: apply to all text content so no
3936        // individual tool can accidentally leak secrets.
3937        if self.state.privacy.redaction_enabled {
3938            result.map(|mut r| {
3939                for item in &mut r.content {
3940                    if let RawContent::Text(ref mut tc) = item.raw {
3941                        tc.text = self.state.privacy.redact_output(&tc.text);
3942                    }
3943                }
3944                r
3945            })
3946        } else {
3947            result
3948        }
3949    }
3950
3951    fn get_tool(&self, name: &str) -> Option<Tool> {
3952        if !self.state.privacy.is_tool_enabled(name) {
3953            return None;
3954        }
3955        Self::tool_router().get(name).cloned()
3956    }
3957
3958    async fn list_resources(
3959        &self,
3960        _request: Option<PaginatedRequestParams>,
3961        _context: RequestContext<RoleServer>,
3962    ) -> Result<ListResourcesResult, ErrorData> {
3963        Ok(ListResourcesResult {
3964            resources: vec![
3965                RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
3966                    .with_description(
3967                        "Live IPC call log — all commands invoked between frontend and backend",
3968                    )
3969                    .with_mime_type("application/json")
3970                    .no_annotation(),
3971                RawResource::new(RESOURCE_URI_WINDOWS, "windows")
3972                    .with_description(
3973                        "Current state of all Tauri windows — position, size, visibility, focus",
3974                    )
3975                    .with_mime_type("application/json")
3976                    .no_annotation(),
3977                RawResource::new(RESOURCE_URI_STATE, "state")
3978                    .with_description(
3979                        "Victauri plugin state — event count, registered commands, memory stats",
3980                    )
3981                    .with_mime_type("application/json")
3982                    .no_annotation(),
3983            ],
3984            ..Default::default()
3985        })
3986    }
3987
3988    async fn read_resource(
3989        &self,
3990        request: ReadResourceRequestParams,
3991        _context: RequestContext<RoleServer>,
3992    ) -> Result<ReadResourceResult, ErrorData> {
3993        let uri = &request.uri;
3994        let json = match uri.as_str() {
3995            RESOURCE_URI_IPC_LOG => {
3996                if let Ok(json) = self
3997                    .eval_with_return("return window.__VICTAURI__?.getIpcLog()", None)
3998                    .await
3999                {
4000                    json
4001                } else {
4002                    let calls = self.state.event_log.ipc_calls();
4003                    serde_json::to_string_pretty(&calls)
4004                        .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4005                }
4006            }
4007            RESOURCE_URI_WINDOWS => {
4008                let states = self.bridge.get_window_states(None);
4009                serde_json::to_string_pretty(&states)
4010                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4011            }
4012            RESOURCE_URI_STATE => {
4013                let state_json = serde_json::json!({
4014                    "events_captured": self.state.event_log.len(),
4015                    "commands_registered": self.state.registry.count(),
4016                    "memory": crate::memory::current_stats(),
4017                    "port": self.state.port.load(Ordering::Relaxed),
4018                });
4019                serde_json::to_string_pretty(&state_json)
4020                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4021            }
4022            _ => {
4023                return Err(ErrorData::resource_not_found(
4024                    format!("unknown resource: {uri}"),
4025                    None,
4026                ));
4027            }
4028        };
4029
4030        let json = if self.state.privacy.redaction_enabled {
4031            self.state.privacy.redact_output(&json)
4032        } else {
4033            json
4034        };
4035
4036        Ok(ReadResourceResult::new(vec![ResourceContents::text(
4037            json, uri,
4038        )]))
4039    }
4040
4041    async fn subscribe(
4042        &self,
4043        request: SubscribeRequestParams,
4044        _context: RequestContext<RoleServer>,
4045    ) -> Result<(), ErrorData> {
4046        let uri = &request.uri;
4047        match uri.as_str() {
4048            RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
4049                self.subscriptions.lock().await.insert(uri.clone());
4050                tracing::info!("Client subscribed to resource: {uri}");
4051                Ok(())
4052            }
4053            _ => Err(ErrorData::resource_not_found(
4054                format!("unknown resource: {uri}"),
4055                None,
4056            )),
4057        }
4058    }
4059
4060    async fn unsubscribe(
4061        &self,
4062        request: UnsubscribeRequestParams,
4063        _context: RequestContext<RoleServer>,
4064    ) -> Result<(), ErrorData> {
4065        self.subscriptions.lock().await.remove(&request.uri);
4066        tracing::info!("Client unsubscribed from resource: {}", request.uri);
4067        Ok(())
4068    }
4069}
4070
4071/// Build a JS expression that takes an array of log entries (`source_expr`),
4072/// keeps at most `limit` of the most recent, and truncates any per-entry field
4073/// larger than [`MAX_LOG_FIELD_BYTES`]. This keeps IPC/network log results under
4074/// the eval size cap on busy apps where individual entries carry large bodies.
4075///
4076/// The returned code is a complete `return (...)` statement.
4077fn trimmed_log_js(source_expr: &str, limit: usize) -> String {
4078    let mb = MAX_LOG_FIELD_BYTES;
4079    format!(
4080        r"return (function() {{
4081            var MB = {mb};
4082            function trimField(v) {{
4083                if (typeof v === 'string') {{
4084                    return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
4085                }}
4086                if (v && typeof v === 'object') {{
4087                    var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }}
4088                    if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }}
4089                }}
4090                return v;
4091            }}
4092            function trimEntry(e) {{
4093                if (e == null || typeof e !== 'object') return e;
4094                var out = Array.isArray(e) ? [] : {{}};
4095                for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) out[k] = trimField(e[k]); }}
4096                return out;
4097            }}
4098            var arr = {source_expr} || [];
4099            if (arr.length > {limit}) arr = arr.slice(-{limit});
4100            return arr.map(trimEntry);
4101        }})()"
4102    )
4103}
4104
4105/// Unwrap the `{"__victauri_ok": <val>, "__victauri_type": <t>}` (or
4106/// `{"__victauri_err": <msg>}`) envelope produced by the eval bridge into the
4107/// value/error string returned to callers.
4108///
4109/// Parsing uses `serde_json`'s default recursion limit (it is intentionally NOT
4110/// disabled — an unbounded recursive parse of a pathologically deep result
4111/// overflows the worker thread stack and crashes the host). When the parse
4112/// fails because the value is too deeply nested, the envelope is stripped by
4113/// string slicing (no recursion) so the actual value is still returned rather
4114/// than leaking the raw envelope string.
4115fn unwrap_eval_envelope(raw: String) -> Result<String, String> {
4116    if let Ok(envelope) = serde_json::from_str::<serde_json::Value>(&raw) {
4117        if let Some(err) = envelope.get("__victauri_err") {
4118            return Err(format!(
4119                "JavaScript error: {}",
4120                err.as_str().unwrap_or("unknown error")
4121            ));
4122        }
4123        if envelope.get("__victauri_ok").is_some() {
4124            let js_type = envelope
4125                .get("__victauri_type")
4126                .and_then(|t| t.as_str())
4127                .unwrap_or("value");
4128            return match js_type {
4129                "undefined" => Ok("undefined".to_string()),
4130                "null" => Ok("null".to_string()),
4131                _ => Ok(serde_json::to_string(&envelope["__victauri_ok"])
4132                    .unwrap_or_else(|_| "null".to_string())),
4133            };
4134        }
4135    }
4136    // Fallback for results too deeply nested for the recursion-limited parser.
4137    if let Some(after) = raw.strip_prefix(r#"{"__victauri_ok":"#)
4138        && let Some(idx) = after.rfind(r#","__victauri_type":"#)
4139    {
4140        return Ok(after[..idx].to_string());
4141    }
4142    if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) {
4143        let msg = after.trim_end_matches('}').trim_matches('"');
4144        return Err(format!("JavaScript error: {msg}"));
4145    }
4146    Ok(raw)
4147}
4148
4149/// Statement keywords where a leading `return` would be a syntax error.
4150const STMT_STARTS: &[&str] = &[
4151    "return ",
4152    "return;",
4153    "return\n",
4154    "return\t",
4155    "if ",
4156    "if(",
4157    "for ",
4158    "for(",
4159    "while ",
4160    "while(",
4161    "switch ",
4162    "switch(",
4163    "try ",
4164    "try{",
4165    "const ",
4166    "let ",
4167    "var ",
4168    "function ",
4169    "function(",
4170    "function*",
4171    "class ",
4172    "throw ",
4173    "do ",
4174    "do{",
4175    "{",
4176    "async function",
4177    "debugger",
4178];
4179
4180/// String/template/comment scan state for [`should_prepend_return`].
4181#[derive(PartialEq, Clone, Copy)]
4182enum ScanState {
4183    Code,
4184    SingleQuote,
4185    DoubleQuote,
4186    Template,
4187}
4188
4189/// Decide whether to wrap `code` with a leading `return`.
4190///
4191/// Only a single bare expression should get `return` prepended. Code that is a
4192/// multi-statement block, contains an explicit top-level `return`, or starts
4193/// with a statement keyword is used as-is — prepending `return` to such code
4194/// would execute only the first statement and silently discard the rest.
4195///
4196/// The scan is string/template/comment-aware and only treats a `;` or an
4197/// explicit `return` token as significant when it occurs at bracket depth 0
4198/// outside of any string, template literal, or comment.
4199fn should_prepend_return(code: &str) -> bool {
4200    use ScanState::{Code, DoubleQuote, SingleQuote, Template};
4201
4202    let code = code.trim();
4203    if code.is_empty() {
4204        return false;
4205    }
4206
4207    if STMT_STARTS.iter().any(|k| code.starts_with(k)) {
4208        return false;
4209    }
4210
4211    let bytes = code.as_bytes();
4212    let mut i = 0;
4213    let mut depth: i32 = 0;
4214    let mut state = ScanState::Code;
4215
4216    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$';
4217    // Is there a top-level `return` token starting at byte `i` (word-bounded)?
4218    let is_return_token = |i: usize| -> bool {
4219        let prev_ok = i == 0 || !is_ident(bytes[i - 1]);
4220        prev_ok
4221            && code[i..].starts_with("return")
4222            && bytes.get(i + 6).copied().is_none_or(|b| !is_ident(b))
4223    };
4224
4225    while i < bytes.len() {
4226        let c = bytes[i];
4227        match state {
4228            Code => match c {
4229                b'\'' => state = SingleQuote,
4230                b'"' => state = DoubleQuote,
4231                b'`' => state = Template,
4232                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => {
4233                    while i < bytes.len() && bytes[i] != b'\n' {
4234                        i += 1;
4235                    }
4236                    continue;
4237                }
4238                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
4239                    i += 2;
4240                    while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
4241                        i += 1;
4242                    }
4243                    i += 2;
4244                    continue;
4245                }
4246                b'(' | b'[' | b'{' => depth += 1,
4247                b')' | b']' | b'}' => depth -= 1,
4248                // A top-level `;` with more code after it == multi-statement.
4249                b';' if depth <= 0 && !code[i + 1..].trim().is_empty() => return false,
4250                // An explicit top-level `return` token means the code already returns.
4251                b'r' if depth <= 0 && is_return_token(i) => return false,
4252                _ => {}
4253            },
4254            SingleQuote => {
4255                if c == b'\\' {
4256                    i += 1;
4257                } else if c == b'\'' {
4258                    state = Code;
4259                }
4260            }
4261            DoubleQuote => {
4262                if c == b'\\' {
4263                    i += 1;
4264                } else if c == b'"' {
4265                    state = Code;
4266                }
4267            }
4268            Template => {
4269                if c == b'\\' {
4270                    i += 1;
4271                } else if c == b'`' {
4272                    state = Code;
4273                }
4274            }
4275        }
4276        i += 1;
4277    }
4278
4279    true
4280}
4281
4282#[cfg(test)]
4283mod prop_tests {
4284    //! Property-based tests for the eval auto-return heuristic — the code that
4285    //! caused the worst bug in the system (silent corruption of multi-statement
4286    //! eval) and has bitten twice. These generate many JS-ish snippets and
4287    //! assert the invariants that keep eval correct.
4288    use super::should_prepend_return;
4289    use proptest::prelude::*;
4290
4291    /// A small set of non-keyword identifier-ish expressions.
4292    fn ident() -> impl Strategy<Value = String> {
4293        prop_oneof![
4294            Just("a".to_string()),
4295            Just("x".to_string()),
4296            Just("foo".to_string()),
4297            Just("window.x".to_string()),
4298            Just("document.title".to_string()),
4299            Just("obj.prop".to_string()),
4300            Just("arr[0]".to_string()),
4301            Just("localStorage".to_string()),
4302        ]
4303    }
4304
4305    /// A single bare expression: never starts with a statement keyword, has no
4306    /// top-level `;`, and contains no `return`.
4307    fn bare_expr() -> impl Strategy<Value = String> {
4308        prop_oneof![
4309            ident(),
4310            (ident(), ident()).prop_map(|(a, b)| format!("{a} + {b}")),
4311            (ident(), ident()).prop_map(|(a, b)| format!("{a}({b})")),
4312            ident().prop_map(|a| format!("{a}.length")),
4313            any::<u16>().prop_map(|n| n.to_string()),
4314        ]
4315    }
4316
4317    proptest! {
4318        /// Must never panic or hang on ANY input — including malformed code,
4319        /// unbalanced quotes, and arbitrary unicode (the scanner indexes bytes).
4320        #[test]
4321        fn never_panics_on_arbitrary_input(s in ".{0,256}") {
4322            let _ = should_prepend_return(&s);
4323        }
4324
4325        /// A single bare expression is safe to wrap with `return` → true.
4326        #[test]
4327        fn bare_expressions_are_prepended(e in bare_expr()) {
4328            prop_assert!(should_prepend_return(&e), "bare expr not prepended: {e:?}");
4329        }
4330
4331        /// THE critical bug class: `<expr>; return <expr>` must NOT be prepended
4332        /// (else `return <expr>;` runs and the rest is silently discarded).
4333        #[test]
4334        fn semicolon_multistatement_with_return_never_prepended(
4335            setup in bare_expr(), ret in bare_expr()
4336        ) {
4337            let code = format!("{setup}; return {ret}");
4338            prop_assert!(!should_prepend_return(&code), "would corrupt: {code:?}");
4339        }
4340
4341        /// Newline-separated (ASI) explicit return must also be left as-is.
4342        #[test]
4343        fn newline_explicit_return_never_prepended(pre in bare_expr(), ret in bare_expr()) {
4344            let code = format!("{pre}\nreturn {ret}");
4345            prop_assert!(!should_prepend_return(&code), "explicit return prepended: {code:?}");
4346        }
4347
4348        /// `;` or the word `return` INSIDE a string literal must not trigger a
4349        /// false multi-statement split — a bare string is one expression.
4350        #[test]
4351        fn semicolons_and_return_inside_strings_are_ignored(inner in "[a-z0-9;= ]{0,24}") {
4352            // `inner` never contains a quote, so the literal is well-formed.
4353            let code = format!("'do;not;split return {inner}'");
4354            prop_assert!(should_prepend_return(&code), "string literal mis-split: {code:?}");
4355        }
4356    }
4357}
4358
4359#[cfg(test)]
4360mod tests {
4361    use super::*;
4362
4363    #[test]
4364    fn prepend_return_bare_expressions() {
4365        assert!(should_prepend_return("document.title"));
4366        assert!(should_prepend_return("5 + 5"));
4367        assert!(should_prepend_return("\"justexpr\""));
4368        assert!(should_prepend_return("await fetch('/x')"));
4369        assert!(should_prepend_return(
4370            "document.querySelectorAll('a').length"
4371        ));
4372        assert!(should_prepend_return("x ? a : b"));
4373        // Single trailing semicolon on a bare expression is still an expression.
4374        assert!(should_prepend_return("document.title;"));
4375        // Semicolons inside strings must not be treated as boundaries.
4376        assert!(should_prepend_return("'a;b;c'"));
4377        assert!(should_prepend_return("\"x;y\".length"));
4378        // IIFE workaround: the `;` lives inside the arrow body (depth > 0).
4379        assert!(should_prepend_return("(()=>{window.x=5; return 'ok'})()"));
4380    }
4381
4382    #[test]
4383    fn no_prepend_for_statement_blocks() {
4384        // The original silent-corruption cases.
4385        assert!(!should_prepend_return(
4386            "localStorage.setItem('k','v'); return localStorage.getItem('k')"
4387        ));
4388        assert!(!should_prepend_return(
4389            "window.scrollTo(0,50); return window.scrollY"
4390        ));
4391        assert!(!should_prepend_return("console.log('x'); return 123"));
4392        assert!(!should_prepend_return("window.__z=7; return 'ok'"));
4393        // Explicit return without a preceding semicolon (newline-separated).
4394        assert!(!should_prepend_return("window.x = 5\nreturn window.x"));
4395    }
4396
4397    #[test]
4398    fn no_prepend_for_statement_keywords() {
4399        assert!(!should_prepend_return("return 42"));
4400        assert!(!should_prepend_return("const x = 1; return x"));
4401        assert!(!should_prepend_return("let y = 2"));
4402        assert!(!should_prepend_return("var z = 3"));
4403        assert!(!should_prepend_return("if (x) { return 1 }"));
4404        assert!(!should_prepend_return("for (const x of y) doThing(x)"));
4405        assert!(!should_prepend_return("throw new Error('x')"));
4406        assert!(!should_prepend_return("function f(){}"));
4407        assert!(!should_prepend_return("{ a: 1 }")); // object-literal-as-block ambiguity → as-is
4408    }
4409
4410    #[test]
4411    fn empty_code_no_prepend() {
4412        assert!(!should_prepend_return(""));
4413        assert!(!should_prepend_return("   "));
4414    }
4415
4416    #[test]
4417    fn envelope_unwrap_value() {
4418        assert_eq!(
4419            unwrap_eval_envelope(r#"{"__victauri_ok":"4DA","__victauri_type":"value"}"#.into()),
4420            Ok("\"4DA\"".to_string())
4421        );
4422        assert_eq!(
4423            unwrap_eval_envelope(r#"{"__victauri_ok":42,"__victauri_type":"value"}"#.into()),
4424            Ok("42".to_string())
4425        );
4426    }
4427
4428    #[test]
4429    fn envelope_unwrap_undefined_null() {
4430        assert_eq!(
4431            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#.into()),
4432            Ok("undefined".to_string())
4433        );
4434        assert_eq!(
4435            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"null"}"#.into()),
4436            Ok("null".to_string())
4437        );
4438    }
4439
4440    #[test]
4441    fn envelope_unwrap_error() {
4442        let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into());
4443        assert!(r.unwrap_err().contains("boom"));
4444    }
4445
4446    #[test]
4447    fn envelope_unwrap_deeply_nested_does_not_leak() {
4448        // Build an envelope whose value is nested far deeper than serde_json's
4449        // default recursion limit (128). The full parse fails, so the slice
4450        // fallback must return the value — NOT the raw `__victauri_ok` envelope.
4451        let mut value = String::from("0");
4452        for _ in 0..300 {
4453            value = format!("{{\"n\":{value}}}");
4454        }
4455        let raw = format!(r#"{{"__victauri_ok":{value},"__victauri_type":"value"}}"#);
4456        let out = unwrap_eval_envelope(raw).unwrap();
4457        assert!(
4458            out.starts_with(r#"{"n":"#),
4459            "deep value should be unwrapped, got: {}",
4460            &out[..out.len().min(40)]
4461        );
4462        assert!(
4463            !out.contains("__victauri_ok"),
4464            "envelope must not leak into the result"
4465        );
4466    }
4467
4468    #[test]
4469    fn js_string_simple() {
4470        assert_eq!(js_string("hello"), "\"hello\"");
4471    }
4472
4473    #[test]
4474    fn js_string_single_quotes() {
4475        let result = js_string("it's a test");
4476        assert!(result.contains("it's a test"));
4477    }
4478
4479    #[test]
4480    fn js_string_double_quotes() {
4481        let result = js_string(r#"say "hello""#);
4482        assert!(result.contains(r#"\""#));
4483    }
4484
4485    #[test]
4486    fn js_string_backslashes() {
4487        let result = js_string(r"path\to\file");
4488        assert!(result.contains(r"\\"));
4489    }
4490
4491    #[test]
4492    fn js_string_newlines_and_tabs() {
4493        let result = js_string("line1\nline2\ttab");
4494        assert!(result.contains(r"\n"));
4495        assert!(result.contains(r"\t"));
4496        assert!(!result.contains('\n'));
4497    }
4498
4499    #[test]
4500    fn js_string_null_bytes() {
4501        let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
4502        let result = js_string(&input);
4503        // serde_json escapes null bytes as
4504        assert!(result.contains("\\u0000"));
4505        assert!(!result.contains('\0'));
4506    }
4507
4508    #[test]
4509    fn js_string_template_literal_injection() {
4510        let result = js_string("`${alert(1)}`");
4511        // Should not contain unescaped backticks that could break template literals
4512        // serde_json wraps in double quotes, so backticks are safe
4513        assert!(result.starts_with('"'));
4514        assert!(result.ends_with('"'));
4515    }
4516
4517    #[test]
4518    fn js_string_unicode_separators() {
4519        // U+2028 (Line Separator) and U+2029 (Paragraph Separator) are valid in
4520        // JSON strings per RFC 8259, and serde_json passes them through literally.
4521        // Since js_string is used inside JS double-quoted strings (not template
4522        // literals), they are safe in modern JS engines (ES2019+).
4523        let result = js_string("a\u{2028}b\u{2029}c");
4524        // Verify the string is valid JSON that round-trips correctly
4525        let decoded: String = serde_json::from_str(&result).unwrap();
4526        assert_eq!(decoded, "a\u{2028}b\u{2029}c");
4527    }
4528
4529    #[test]
4530    fn js_string_empty() {
4531        assert_eq!(js_string(""), "\"\"");
4532    }
4533
4534    #[test]
4535    fn js_string_html_script_close() {
4536        // </script> in a JS string inside HTML could break out of script tags
4537        let result = js_string("</script><img onerror=alert(1)>");
4538        assert!(result.starts_with('"'));
4539        // The string is JSON-encoded; verify it round-trips safely
4540        let decoded: String = serde_json::from_str(&result).unwrap();
4541        assert_eq!(decoded, "</script><img onerror=alert(1)>");
4542    }
4543
4544    #[test]
4545    fn js_string_very_long() {
4546        let long = "a".repeat(100_000);
4547        let result = js_string(&long);
4548        assert!(result.len() >= 100_002); // quotes + content
4549    }
4550
4551    // ── URL validation tests ────────────────────────────────────────────────
4552
4553    #[test]
4554    fn url_allows_http() {
4555        assert!(validate_url("http://example.com", false).is_ok());
4556    }
4557
4558    #[test]
4559    fn url_allows_https() {
4560        assert!(validate_url("https://example.com/path?q=1", false).is_ok());
4561    }
4562
4563    #[test]
4564    fn url_allows_http_localhost() {
4565        assert!(validate_url("http://localhost:3000", false).is_ok());
4566    }
4567
4568    #[test]
4569    fn url_blocks_file_by_default() {
4570        let err = validate_url("file:///etc/passwd", false).unwrap_err();
4571        assert!(err.contains("file"), "error should mention the file scheme");
4572    }
4573
4574    #[test]
4575    fn url_allows_file_when_opted_in() {
4576        assert!(validate_url("file:///tmp/test.html", true).is_ok());
4577    }
4578
4579    #[test]
4580    fn url_blocks_javascript() {
4581        assert!(validate_url("javascript:alert(1)", false).is_err());
4582    }
4583
4584    #[test]
4585    fn url_blocks_javascript_case_insensitive() {
4586        assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
4587    }
4588
4589    #[test]
4590    fn url_blocks_data_scheme() {
4591        assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
4592    }
4593
4594    #[test]
4595    fn url_blocks_vbscript() {
4596        assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
4597    }
4598
4599    #[test]
4600    fn url_rejects_invalid() {
4601        assert!(validate_url("not a url at all", false).is_err());
4602    }
4603
4604    #[test]
4605    fn url_strips_control_chars() {
4606        // Control characters should be stripped, leaving a valid URL
4607        let input = format!("http://example{}com", '\0');
4608        assert!(validate_url(&input, false).is_ok());
4609    }
4610
4611    // ── CSS color sanitization tests ───────────────────────────────────────
4612
4613    #[test]
4614    fn css_color_valid_hex() {
4615        assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
4616        assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
4617        assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
4618    }
4619
4620    #[test]
4621    fn css_color_valid_rgb() {
4622        assert_eq!(
4623            sanitize_css_color("rgb(255, 0, 0)").unwrap(),
4624            "rgb(255, 0, 0)"
4625        );
4626        assert_eq!(
4627            sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
4628            "rgba(0, 0, 0, 0.5)"
4629        );
4630    }
4631
4632    #[test]
4633    fn css_color_valid_named() {
4634        assert_eq!(sanitize_css_color("red").unwrap(), "red");
4635        assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
4636    }
4637
4638    #[test]
4639    fn css_color_valid_hsl() {
4640        assert_eq!(
4641            sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
4642            "hsl(120, 50%, 50%)"
4643        );
4644    }
4645
4646    #[test]
4647    fn css_color_rejects_too_long() {
4648        let long = "a".repeat(101);
4649        assert!(sanitize_css_color(&long).is_err());
4650    }
4651
4652    #[test]
4653    fn css_color_rejects_backslash_escapes() {
4654        assert!(sanitize_css_color(r"red\00").is_err());
4655        assert!(sanitize_css_color(r"\72\65\64").is_err());
4656    }
4657
4658    #[test]
4659    fn css_color_rejects_url_injection() {
4660        assert!(sanitize_css_color("url(http://evil.com)").is_err());
4661        assert!(sanitize_css_color("URL(http://evil.com)").is_err());
4662    }
4663
4664    #[test]
4665    fn css_color_rejects_expression_injection() {
4666        assert!(sanitize_css_color("expression(alert(1))").is_err());
4667        assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
4668    }
4669
4670    #[test]
4671    fn css_color_rejects_import() {
4672        assert!(sanitize_css_color("@import url(evil.css)").is_err());
4673    }
4674
4675    #[test]
4676    fn css_color_rejects_semicolons_and_braces() {
4677        assert!(sanitize_css_color("red; background: url(evil)").is_err());
4678        assert!(sanitize_css_color("red} body { color: blue").is_err());
4679    }
4680
4681    #[test]
4682    fn css_color_rejects_special_chars() {
4683        assert!(sanitize_css_color("red<script>").is_err());
4684        assert!(sanitize_css_color("red\"onload=alert").is_err());
4685        assert!(sanitize_css_color("red'onclick=alert").is_err());
4686    }
4687
4688    #[test]
4689    fn css_color_trims_whitespace() {
4690        assert_eq!(sanitize_css_color("  red  ").unwrap(), "red");
4691    }
4692
4693    #[test]
4694    fn css_color_empty_string() {
4695        assert_eq!(sanitize_css_color("").unwrap(), "");
4696    }
4697}