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