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