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 authz;
8mod backend_params;
9mod compound_params;
10mod helpers;
11mod introspection_params;
12mod other_params;
13mod rest;
14mod server;
15mod verification_params;
16mod webview_params;
17mod window_params;
18
19use std::collections::{HashMap, HashSet};
20use std::sync::Arc;
21use std::sync::atomic::{AtomicBool, Ordering};
22
23use rmcp::handler::server::tool::ToolCallContext;
24use rmcp::handler::server::wrapper::Parameters;
25use rmcp::model::{
26    AnnotateAble, CallToolRequestParams, CallToolResult, Content, ListResourcesResult,
27    ListToolsResult, PaginatedRequestParams, RawContent, RawResource, ReadResourceRequestParams,
28    ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, SubscribeRequestParams,
29    Tool, UnsubscribeRequestParams,
30};
31use rmcp::service::RequestContext;
32use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_router};
33use tokio::sync::Mutex;
34
35use crate::VictauriState;
36use crate::bridge::WebviewBridge;
37
38use helpers::{
39    RecoveryHint, build_ghost_report, ghost_ipc_outcomes_js, ghost_ipc_projection_js,
40    ipc_catalog_projection_js, ipc_timing_projection_js, ipc_timing_stats, js_string, json_result,
41    json_truthy, merge_command_catalog, missing_param, sanitize_css_color, sanitize_injected_css,
42    tool_disabled, tool_error, tool_error_with_hint, validate_url,
43};
44
45// MCP tool *parameter* types are an internal protocol surface: they are deserialized
46// from MCP/JSON, used only inside this crate's (private) tool methods, and change every
47// release as actions/fields are added. They are deliberately NOT part of the public API
48// (`pub(crate)`, not `pub use`), so adding a tool action or field is not a breaking change
49// and `cargo semver-checks` stays meaningful. Only `server::*` (build_app*,
50// VictauriMcpHandler) is the public MCP surface consumers actually use.
51pub(crate) use backend_params::*;
52pub(crate) use compound_params::*;
53pub(crate) use introspection_params::*;
54pub(crate) use other_params::{
55    AppStateParams, DiagnosticsParams, FindElementsParams, ResolveCommandParams,
56    SemanticAssertParams, WaitCondition, WaitForParams,
57};
58pub use server::*;
59pub(crate) use verification_params::*;
60pub(crate) use webview_params::*;
61pub(crate) use window_params::*;
62
63// ── MCP Handler ──────────────────────────────────────────────────────────────
64
65/// Maximum number of in-flight JavaScript eval requests. Prevents unbounded
66/// growth of the `pending_evals` map if callbacks are never resolved.
67pub(crate) const MAX_PENDING_EVALS: usize = 100;
68
69fn chrono_now() -> String {
70    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
71}
72
73/// Maximum length of JavaScript code accepted by the `eval_js` tool (1 MB).
74const MAX_EVAL_CODE_LEN: usize = 1_000_000;
75
76/// Maximum length of a JavaScript eval return value (5 MB).
77/// Results exceeding this are truncated to prevent memory exhaustion.
78const MAX_EVAL_RESULT_LEN: usize = 5_000_000;
79
80/// How long the eval parse-watchdog waits for the user-code script to begin executing
81/// before reporting a likely syntax error. A parse error means the script never runs (so
82/// it never marks itself "started"); this caps that failure at ~0.75s instead of the full
83/// eval timeout, while still leaving valid-but-slow code to run to the real timeout.
84const PARSE_WATCHDOG_MS: u64 = 750;
85
86/// Default number of entries returned by IPC/network log tools when no explicit
87/// `limit` is given. Prevents busy apps (large logs) from exceeding the eval cap.
88const DEFAULT_LOG_LIMIT: usize = 100;
89
90/// Per-field byte cap applied to each IPC/network log entry before serialization.
91/// Large request/response bodies are truncated with a marker so the aggregate
92/// log stays well under [`MAX_EVAL_RESULT_LEN`] even on heavy-traffic apps.
93const MAX_LOG_FIELD_BYTES: usize = 4096;
94
95/// Hard cap on entries returned by `list_app_dir` (recursive). Without it a
96/// directory with millions of files (or a wide tree at max depth) would build an
97/// unbounded result Vec and blow the eval/output cap (audit B7). When hit, the
98/// listing stops and the response is marked `truncated: true`.
99const MAX_DIR_ENTRIES: usize = 10_000;
100
101/// `db_health` performs integrity checks and table counts against app-owned
102/// databases. Bound the diagnostic so a large or adversarial DB cannot hold a
103/// blocking worker indefinitely or return an unbounded schema listing.
104#[cfg(feature = "sqlite")]
105const DB_HEALTH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
106#[cfg(feature = "sqlite")]
107const DB_HEALTH_PROGRESS_OPS: i32 = 10_000;
108#[cfg(feature = "sqlite")]
109const MAX_DB_HEALTH_TABLES: usize = 1_000;
110#[cfg(feature = "sqlite")]
111const MAX_DB_HEALTH_TABLE_BYTES: usize = 1_000_000;
112#[cfg(feature = "sqlite")]
113const MAX_DB_HEALTH_CELL_BYTES: i32 = 1_048_576;
114
115const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
116const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
117const RESOURCE_URI_STATE: &str = "victauri://state";
118
119/// Map an MCP resource URI to the privacy capability that gates its
120/// tool-equivalent read. Resources are served outside the tool dispatcher, so
121/// this lets `read_resource`/`subscribe` apply the same privacy matrix (audit
122/// B1). Returns `None` for an unknown URI (handled as not-found downstream).
123fn resource_required_capability(uri: &str) -> Option<&'static str> {
124    match uri {
125        // Reading the IPC log via a resource == the `logs ipc` tool action.
126        RESOURCE_URI_IPC_LOG => Some("logs.ipc"),
127        // Window states == the `window list` action.
128        RESOURCE_URI_WINDOWS => Some("window.list"),
129        // The state summary == reading plugin info.
130        RESOURCE_URI_STATE => Some("get_plugin_info"),
131        _ => None,
132    }
133}
134
135const BRIDGE_VERSION: &str = env!("CARGO_PKG_VERSION");
136
137const SAFE_ENV_PREFIXES: &[&str] = &[
138    "HOME",
139    "USER",
140    "LANG",
141    "LC_",
142    "TERM",
143    "SHELL",
144    "DISPLAY",
145    "XDG_",
146    // Only Tauri's build-env namespace, NOT all of TAURI_ — the latter is an
147    // app-custom namespace that can hold secrets (audit #5).
148    "TAURI_ENV_",
149    "VICTAURI_",
150    "NODE_ENV",
151    "OS",
152    "HOSTNAME",
153    "PWD",
154    "SHLVL",
155    "LOGNAME",
156];
157
158/// Substrings that mark an env var as a secret. Even when a name matches a
159/// `SAFE_ENV_PREFIXES` entry it is dropped if it contains one of these — a prefix
160/// like `TAURI_`/`VICTAURI_` otherwise leaks `TAURI_SIGNING_PRIVATE_KEY`,
161/// `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, or `VICTAURI_AUTH_TOKEN` (audit #5).
162const SECRET_ENV_SUBSTRINGS: &[&str] = &[
163    "TOKEN",
164    "SECRET",
165    "PASS", // PASSWORD, PASSWD, PASSPHRASE
166    "PRIVATE",
167    "CREDENTIAL",
168    "APIKEY",
169    "AUTH",
170    "_KEY",
171    "DSN", // connection strings with embedded creds
172    "PAT", // personal access token
173    "JWT",
174    "BEARER",
175    "SESSION",
176    "COOKIE",
177    "SALT",
178    "CERT",
179    "SIGN", // signing keys/material
180    "LICENSE",
181];
182
183/// Whether an env var name is safe to surface via `app_info`: it must match a
184/// known-safe prefix AND not look like a secret (audit #5).
185fn is_safe_env_key(key: &str) -> bool {
186    let upper = key.to_uppercase();
187    SAFE_ENV_PREFIXES
188        .iter()
189        .any(|prefix| upper.starts_with(prefix))
190        && !SECRET_ENV_SUBSTRINGS.iter().any(|s| upper.contains(s))
191}
192
193/// MCP tool handler that dispatches tool calls to the webview bridge and state.
194#[derive(Clone)]
195pub struct VictauriMcpHandler {
196    state: Arc<VictauriState>,
197    bridge: Arc<dyn WebviewBridge>,
198    subscriptions: Arc<Mutex<HashSet<String>>>,
199    bridge_checked: Arc<AtomicBool>,
200    /// Window keys whose previous eval timed out. Retained only to annotate the
201    /// error on the *next* eval (the bridge is probed before every eval anyway).
202    timed_out_labels: Arc<Mutex<HashSet<String>>>,
203}
204
205#[tool_router]
206impl VictauriMcpHandler {
207    // ── Standalone Tools ────────────────────────────────────────────────────
208
209    #[tool(
210        description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.",
211        annotations(
212            read_only_hint = false,
213            destructive_hint = true,
214            idempotent_hint = false,
215            open_world_hint = false
216        )
217    )]
218    async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
219        if !self.state.privacy.is_tool_enabled("eval_js") {
220            return tool_disabled("eval_js");
221        }
222        if params.code.len() > MAX_EVAL_CODE_LEN {
223            return tool_error("code exceeds maximum length (1 MB)");
224        }
225        match self
226            .eval_with_return(&params.code, params.webview_label.as_deref())
227            .await
228        {
229            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
230            Err(e) => tool_error(e),
231        }
232    }
233
234    #[tool(
235        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).",
236        annotations(
237            read_only_hint = true,
238            destructive_hint = false,
239            idempotent_hint = true,
240            open_world_hint = false
241        )
242    )]
243    async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
244        let format = params.format.unwrap_or(SnapshotFormat::Compact);
245        let format_str = match format {
246            SnapshotFormat::Compact => "compact",
247            SnapshotFormat::Json => "json",
248        };
249        let code = format!(
250            "return window.__VICTAURI__?.snapshot({})",
251            js_string(format_str)
252        );
253        self.eval_bridge(&code, params.webview_label.as_deref())
254            .await
255    }
256
257    #[tool(
258        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.",
259        annotations(
260            read_only_hint = true,
261            destructive_hint = false,
262            idempotent_hint = true,
263            open_world_hint = false
264        )
265    )]
266    async fn find_elements(
267        &self,
268        Parameters(params): Parameters<FindElementsParams>,
269    ) -> CallToolResult {
270        let mut parts: Vec<String> = Vec::new();
271        if let Some(t) = &params.text {
272            parts.push(format!("text: {}", js_string(t)));
273        }
274        if let Some(r) = &params.role {
275            parts.push(format!("role: {}", js_string(r)));
276        }
277        if let Some(tid) = &params.test_id {
278            parts.push(format!("test_id: {}", js_string(tid)));
279        }
280        if let Some(c) = params.css.as_ref().or(params.selector.as_ref()) {
281            parts.push(format!("css: {}", js_string(c)));
282        }
283        if let Some(n) = &params.name {
284            parts.push(format!("name: {}", js_string(n)));
285        }
286        if let Some(max) = params.max_results {
287            parts.push(format!("max_results: {max}"));
288        }
289        if let Some(t) = &params.tag {
290            parts.push(format!("tag: {}", js_string(t)));
291        }
292        if let Some(p) = &params.placeholder {
293            parts.push(format!("placeholder: {}", js_string(p)));
294        }
295        if let Some(a) = &params.alt {
296            parts.push(format!("alt: {}", js_string(a)));
297        }
298        if let Some(ta) = &params.title_attr {
299            parts.push(format!("title_attr: {}", js_string(ta)));
300        }
301        if let Some(l) = &params.label {
302            parts.push(format!("label: {}", js_string(l)));
303        }
304        if let Some(true) = params.exact {
305            parts.push("exact: true".to_string());
306        }
307        if let Some(e) = params.enabled {
308            parts.push(format!("enabled: {e}"));
309        }
310        let code = format!(
311            "return window.__VICTAURI__?.findElements({{ {} }})",
312            parts.join(", ")
313        );
314        match self
315            .eval_with_return(&code, params.webview_label.as_deref())
316            .await
317        {
318            Ok(result) => {
319                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
320                    && let Some(err) = parsed.get("error").and_then(|e| e.as_str())
321                {
322                    return tool_error(err);
323                }
324                CallToolResult::success(vec![Content::text(result)])
325            }
326            Err(e) => tool_error(e),
327        }
328    }
329
330    #[tool(
331        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.",
332        annotations(
333            read_only_hint = false,
334            destructive_hint = true,
335            idempotent_hint = false,
336            open_world_hint = false
337        )
338    )]
339    async fn invoke_command(
340        &self,
341        Parameters(params): Parameters<InvokeCommandParams>,
342    ) -> CallToolResult {
343        if !self.state.privacy.is_invoke_allowed(&params.command) {
344            return tool_disabled("invoke_command");
345        }
346        if !self.state.privacy.is_command_allowed(&params.command) {
347            return tool_error(format!(
348                "command '{}' is blocked by privacy configuration",
349                params.command
350            ));
351        }
352
353        // ── Fault injection check ──
354        if let Some(fault) = self.state.fault_registry.check_and_trigger(&params.command) {
355            match fault {
356                crate::introspection::FaultType::Delay { delay_ms } => {
357                    tracing::info!(
358                        command = %params.command,
359                        delay_ms = delay_ms,
360                        "fault injection: delaying command"
361                    );
362                    tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
363                    // After delay, continue with normal execution below
364                }
365                crate::introspection::FaultType::Error { ref message } => {
366                    tracing::info!(
367                        command = %params.command,
368                        "fault injection: returning error"
369                    );
370                    return tool_error(format!(
371                        "[FAULT INJECTED] command '{}': {message}",
372                        params.command
373                    ));
374                }
375                crate::introspection::FaultType::Drop => {
376                    tracing::info!(
377                        command = %params.command,
378                        "fault injection: dropping response"
379                    );
380                    return CallToolResult::success(vec![Content::text("{}")]);
381                }
382                crate::introspection::FaultType::Corrupt => {
383                    tracing::info!(
384                        command = %params.command,
385                        "fault injection: corrupting response"
386                    );
387                    // Execute normally but mangle the response
388                    let args_json = params.args.unwrap_or(serde_json::json!({}));
389                    let args_str =
390                        serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
391                    let code = format!(
392                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
393                        js_string(&params.command)
394                    );
395                    if let Ok(result) = self
396                        .eval_with_return(&code, params.webview_label.as_deref())
397                        .await
398                    {
399                        let corrupted = format!(
400                            "{{\"__corrupted\":true,\"original_length\":{},\"fault\":\"corrupt\"}}",
401                            result.len()
402                        );
403                        return CallToolResult::success(vec![Content::text(corrupted)]);
404                    }
405                    return CallToolResult::success(vec![Content::text(
406                        "{\"__corrupted\":true,\"fault\":\"corrupt\",\"note\":\"original invocation also failed\"}",
407                    )]);
408                }
409            }
410        }
411
412        // ── Normal execution with timing ──
413        let start = std::time::Instant::now();
414        let args_json = params.args.unwrap_or(serde_json::json!({}));
415        let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
416        let code = format!(
417            "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
418            js_string(&params.command)
419        );
420        let result = self
421            .eval_with_return(&code, params.webview_label.as_deref())
422            .await;
423        let elapsed = start.elapsed();
424        self.state.command_timings.record(&params.command, elapsed);
425
426        match result {
427            Ok(result) => {
428                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
429                    && let Some(err) = parsed.get("__error").and_then(|e| e.as_str())
430                {
431                    return tool_error(format!(
432                        "command '{}' returned error: {err}",
433                        params.command
434                    ));
435                }
436                CallToolResult::success(vec![Content::text(result)])
437            }
438            Err(e) => tool_error(format!("invoke_command failed: {e}")),
439        }
440    }
441
442    #[tool(
443        description = "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Works on Windows (PrintWindow), macOS (CGWindowListCreateImage), and Linux X11/XWayland. Pure Wayland fails safely because its available fallback would capture the full desktop rather than the requested window. A hidden (non-visible) window has no on-screen surface to capture, so requesting one returns a clear error (show it first via `window` manage_action=show) rather than a stale or wrong-window image.",
444        annotations(
445            read_only_hint = true,
446            destructive_hint = false,
447            idempotent_hint = true,
448            open_world_hint = false
449        )
450    )]
451    async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
452        if !self.state.privacy.is_tool_enabled("screenshot") {
453            return tool_disabled("screenshot");
454        }
455        // Resolve the EXACT window to capture and require it to be visible BEFORE touching the
456        // OS handle. A native screenshot captures the on-screen surface; a hidden window has
457        // none, so the OS capture path (PrintWindow / CGWindowListCreateImage) silently returns
458        // stale, empty, or ANOTHER window's pixels with no error — an agent can't tell a wrong
459        // image from a right one, so a silent wrong-window image is worse than a clear failure.
460        // Two failure modes, both closed here:
461        //   1. (live-4DA, 2026-06-16) an explicitly-requested hidden window (label:"briefing")
462        //      returned the MAIN window's pixels.
463        //   2. (GPT audit, P2) `screenshot {}` with no label: the default resolver
464        //      `find_window(None)` prefers "main" UNCONDITIONALLY, so an app that hides main but
465        //      leaves a secondary window visible captured hidden main. We do NOT change
466        //      `find_window` — other callers (e.g. eval) may legitimately target a hidden window
467        //      — instead the screenshot tool resolves its own VISIBLE target and passes it
468        //      explicitly to `get_native_handle`.
469        // Note (acknowledged residual): resolve -> handle -> capture are separate calls, so a
470        // window hidden in the gap is still TOCTOU. The worst case is the same usability failure
471        // (a wrong image), not a security boundary; the guard makes the common, deterministic
472        // case correct.
473        let states = self.bridge.get_window_states(None);
474        let target_label: String = if let Some(label) = params.window_label.as_deref() {
475            // An explicit label that the enumerator reports hidden is rejected; visible or
476            // unknown labels fall through (an unknown one lets get_native_handle produce the
477            // canonical "window not found" rather than inventing an error here).
478            if states.iter().any(|s| s.label == label && !s.visible) {
479                return tool_error(format!(
480                    "window '{label}' is not visible — a native screenshot captures the \
481                     on-screen surface, and a hidden window has none (the OS capture would \
482                     return stale or another window's pixels). Show it first \
483                     (window action=manage manage_action=show label={label}), then capture."
484                ));
485            }
486            label.to_string()
487        } else {
488            // No label: prefer a visible "main", else the first visible window. NEVER silently
489            // fall back to a hidden window (the P2 bug) — error instead.
490            let pick = states
491                .iter()
492                .find(|s| s.label == "main" && s.visible)
493                .or_else(|| states.iter().find(|s| s.visible));
494            match pick {
495                Some(st) => st.label.clone(),
496                None => {
497                    return tool_error(
498                        "no visible window to capture — every window is hidden (or the UI is \
499                         not responding). Show one first (window action=manage \
500                         manage_action=show label=<label>), then capture."
501                            .to_string(),
502                    );
503                }
504            }
505        };
506        match self.bridge.get_native_handle(Some(&target_label)) {
507            Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
508                Ok(png_bytes) => {
509                    use base64::Engine;
510                    let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
511                    CallToolResult::success(vec![Content::image(b64, "image/png")])
512                }
513                Err(e) => tool_error(format!("screenshot capture failed: {e}")),
514            },
515            Err(e) => tool_error(format!("cannot get window handle: {e}")),
516        }
517    }
518
519    #[tool(
520        description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.",
521        annotations(
522            read_only_hint = true,
523            destructive_hint = false,
524            idempotent_hint = true,
525            open_world_hint = false
526        )
527    )]
528    async fn verify_state(
529        &self,
530        Parameters(params): Parameters<VerifyStateParams>,
531    ) -> CallToolResult {
532        if !self.state.privacy.is_tool_enabled("eval_js") {
533            return tool_disabled("verify_state requires eval_js capability");
534        }
535        let code = format!("return ({})", params.frontend_expr);
536        let frontend_json = match self
537            .eval_with_return(&code, params.webview_label.as_deref())
538            .await
539        {
540            Ok(result) => result,
541            Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
542        };
543
544        let frontend_state: serde_json::Value = match serde_json::from_str(&frontend_json) {
545            Ok(v) => v,
546            Err(e) => {
547                return tool_error(format!(
548                    "frontend expression did not return valid JSON: {e}"
549                ));
550            }
551        };
552
553        let backend_state = if let Some(state) = params.backend_state {
554            state
555        } else if let Some(ref cmd) = params.backend_command {
556            // Gate on BOTH is_invoke_allowed and is_command_allowed, matching
557            // invoke_command and the contract/replay paths — backend_command
558            // previously checked only the blocklist (audit #30 follow-up).
559            if !self.state.privacy.is_invoke_allowed(cmd)
560                || !self.state.privacy.is_command_allowed(cmd)
561            {
562                return tool_error(format!(
563                    "command '{cmd}' is blocked by privacy configuration"
564                ));
565            }
566            let args = params.backend_args.unwrap_or(serde_json::json!({}));
567            let args_str = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
568            let invoke_code = format!(
569                "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
570                js_string(cmd)
571            );
572            match self
573                .eval_with_return(&invoke_code, params.webview_label.as_deref())
574                .await
575            {
576                Ok(result) => match serde_json::from_str(&result) {
577                    Ok(v) => v,
578                    Err(e) => {
579                        return tool_error(format!(
580                            "backend command '{cmd}' did not return valid JSON: {e}"
581                        ));
582                    }
583                },
584                Err(e) => {
585                    return tool_error(format!("failed to invoke backend command '{cmd}': {e}"));
586                }
587            }
588        } else {
589            return tool_error("either backend_state or backend_command must be provided");
590        };
591
592        let result = victauri_core::verify_state(frontend_state, backend_state);
593        json_result(&result)
594    }
595
596    #[tool(
597        description = "Detect ghost commands (frontend calls with no backend handler) by IPC OUTCOME, not by guessing from Victauri's registry. Returns: `confirmed_ghosts` = commands invoked that NEVER returned success and errored 'not found' — real missing-handler bugs, HIGH confidence and independent of whether the app uses #[inspectable]; `verified_handlers` = count of commands that returned success at least once (they provably HAVE a handler, so they are never flagged — this is why a real command like `set_language` is no longer a false positive); `frontend_only` = the WEAKER candidate tier (invoked, never observed succeeding, NOT a Tauri/plugin framework builtin, and absent from the introspection registry) — confirm against the app's `tauri::generate_handler!` before filing; `excluded_builtins` = framework `plugin:*` commands (never app ghosts); `registry_only` = registered commands never invoked (informational). The `reliability` field describes only `frontend_only`; `confirmed_ghosts` is high-confidence regardless. Reads the JS-side IPC interception log (ACCUMULATES all session traffic). For a clean signal scope with `since_ms` (e.g. 5000) — invoke the suspect action, then call this with `since_ms` — or `logs {action:'clear'}` then exercise the app.",
598        annotations(
599            read_only_hint = true,
600            destructive_hint = false,
601            idempotent_hint = true,
602            open_world_hint = false
603        )
604    )]
605    async fn detect_ghost_commands(
606        &self,
607        Parameters(params): Parameters<GhostCommandParams>,
608    ) -> CallToolResult {
609        // Project a per-command OUTCOME summary in JS ({command, ok, err}, deduped). Ghost
610        // detection is outcome-based (VIC-1): a command that returned success provably has a
611        // handler and is never a ghost; one that errored "not found" is a confirmed ghost.
612        // Aggregating per command keeps this tiny even on a busy app (avoids the eval cap).
613        // When `since_ms` is set, the projection time-windows to the current test's traffic.
614        let code = ghost_ipc_outcomes_js(params.since_ms);
615        let ipc_json = match self
616            .eval_with_return(&code, params.webview_label.as_deref())
617            .await
618        {
619            Ok(r) => r,
620            Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
621        };
622
623        let outcomes: Vec<crate::mcp::helpers::IpcOutcome> = match serde_json::from_str(&ipc_json) {
624            Ok(v) => v,
625            Err(e) => return tool_error(format!("failed to parse IPC log JSON: {e}")),
626        };
627
628        json_result(&build_ghost_report(&outcomes, &self.state.registry))
629    }
630
631    #[tool(
632        description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.",
633        annotations(
634            read_only_hint = true,
635            destructive_hint = false,
636            idempotent_hint = true,
637            open_world_hint = false
638        )
639    )]
640    async fn check_ipc_integrity(
641        &self,
642        Parameters(params): Parameters<IpcIntegrityParams>,
643    ) -> CallToolResult {
644        let threshold = params.stale_threshold_ms.unwrap_or(5000);
645        let code = format!(
646            r"return (function() {{
647                var log = window.__VICTAURI__?.getIpcLog() || [];
648                var now = Date.now();
649                var threshold = {threshold};
650                var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
651                var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
652                var errored = log.filter(function(c) {{ return c.status === 'error'; }});
653                var net = window.__VICTAURI__?.getNetworkLog() || [];
654                var warning = null;
655                if (log.length === 0 && net.length > 5) {{
656                    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.';
657                }}
658                // INTEGRITY = round-trip soundness: no stuck/stale (never-returned) calls.
659                // A command that completed with an Err is a HEALTHY round-trip (it returned)
660                // — every real app exercises error paths, so counting those as 'unhealthy'
661                // would cry wolf. The error_count/errored_calls surface them for visibility,
662                // but only stale calls flip `healthy`.
663                return {{
664                    healthy: stale.length === 0,
665                    total_calls: log.length,
666                    pending_count: pending.length,
667                    stale_count: stale.length,
668                    error_count: errored.length,
669                    stale_calls: stale.slice(0, 20),
670                    errored_calls: errored.slice(0, 20),
671                    warning: warning
672                }};
673            }})()"
674        );
675        self.eval_bridge(&code, params.webview_label.as_deref())
676            .await
677    }
678
679    #[tool(
680        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), expression (poll a JS expression in `value` until truthy or until it equals `expected` — may `await`, e.g. await a fire-and-forget command's status), event (block until the Tauri event named in `value` fires, with `since_ms` look-back). Use expression/event to await async backend work to true completion instead of guessing with a fixed sleep.",
681        annotations(
682            read_only_hint = true,
683            destructive_hint = false,
684            idempotent_hint = true,
685            open_world_hint = false
686        )
687    )]
688    async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
689        let timeout_ms = params.timeout_ms.unwrap_or(10_000).min(120_000);
690        let poll = params.poll_ms.unwrap_or(200).max(20);
691
692        // The `expression` and `event` conditions are awaited server-side (they
693        // poll the eval engine and the captured event bus respectively), so a
694        // fire-and-forget backend command can be awaited to true completion.
695        match params.condition {
696            WaitCondition::Expression => {
697                return self.wait_for_expression(&params, timeout_ms, poll).await;
698            }
699            WaitCondition::Event => {
700                return self.wait_for_event(&params, timeout_ms, poll).await;
701            }
702            _ => {}
703        }
704
705        let value = params
706            .value
707            .as_ref()
708            .map_or_else(|| "null".to_string(), |v| js_string(v));
709        let code = format!(
710            "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
711            js_string(params.condition.as_str())
712        );
713        let eval_timeout = std::time::Duration::from_millis(timeout_ms + 5000);
714        match self
715            .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
716            .await
717        {
718            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
719            Err(e) => tool_error(e),
720        }
721    }
722
723    /// Poll a JS expression until truthy (or `== expected`), server-side.
724    ///
725    /// Level-triggered and race-free: each poll re-evaluates the expression via
726    /// the same engine as `eval_js`, so it may `await`. Eval errors are treated
727    /// as "not yet met" (the target may not exist during startup) and the last
728    /// error is surfaced on timeout.
729    async fn wait_for_expression(
730        &self,
731        params: &WaitForParams,
732        timeout_ms: u64,
733        poll_ms: u64,
734    ) -> CallToolResult {
735        if !self.state.privacy.is_tool_enabled("eval_js") {
736            return tool_disabled("wait_for(expression) requires eval_js capability");
737        }
738        let Some(expr) = params.value.as_deref().filter(|s| !s.is_empty()) else {
739            return missing_param("value", "wait_for(expression)");
740        };
741        let code = format!("return ({expr});");
742        let start = std::time::Instant::now();
743        let deadline = start + std::time::Duration::from_millis(timeout_ms);
744        let poll = std::time::Duration::from_millis(poll_ms);
745        let mut last_value = serde_json::Value::Null;
746        let mut last_error: Option<String> = None;
747
748        loop {
749            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
750            let per_eval = remaining
751                .min(std::time::Duration::from_secs(15))
752                .max(std::time::Duration::from_secs(1));
753            match self
754                .eval_with_return_timeout(&code, params.webview_label.as_deref(), per_eval)
755                .await
756            {
757                Ok(raw) => {
758                    let val = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null);
759                    let met = match &params.expected {
760                        Some(expected) => &val == expected,
761                        None => json_truthy(&val),
762                    };
763                    if met {
764                        return json_result(&serde_json::json!({
765                            "ok": true,
766                            "value": val,
767                            "elapsed_ms": start.elapsed().as_millis() as u64,
768                        }));
769                    }
770                    last_value = val;
771                }
772                Err(e) => last_error = Some(e),
773            }
774
775            if std::time::Instant::now() >= deadline {
776                return json_result(&serde_json::json!({
777                    "ok": false,
778                    "error": format!("timeout after {timeout_ms}ms"),
779                    "last_value": last_value,
780                    "last_error": last_error,
781                    "elapsed_ms": start.elapsed().as_millis() as u64,
782                }));
783            }
784            tokio::time::sleep(
785                poll.min(deadline.saturating_duration_since(std::time::Instant::now())),
786            )
787            .await;
788        }
789    }
790
791    /// Block until a named Tauri event appears on the captured event bus.
792    ///
793    /// Edge-triggered: matches the most recent event whose timestamp is no older
794    /// than `since_ms` before this call began, so an event fired in the gap
795    /// between `invoke_command` and this call is still caught. Polls the
796    /// event-bus ring buffer — no webview eval involved.
797    async fn wait_for_event(
798        &self,
799        params: &WaitForParams,
800        timeout_ms: u64,
801        poll_ms: u64,
802    ) -> CallToolResult {
803        let Some(name) = params.value.as_deref().filter(|s| !s.is_empty()) else {
804            return missing_param("value", "wait_for(event)");
805        };
806        let since_ms = params.since_ms.unwrap_or(2000);
807        let start = std::time::Instant::now();
808        let baseline = chrono::Utc::now()
809            - chrono::TimeDelta::try_milliseconds(since_ms as i64).unwrap_or_default();
810        let deadline = start + std::time::Duration::from_millis(timeout_ms);
811        let poll = std::time::Duration::from_millis(poll_ms);
812
813        loop {
814            // Search newest-first for a matching event no older than the baseline.
815            let matched = self.state.event_bus.events().into_iter().rev().find(|e| {
816                e.name == name
817                    && chrono::DateTime::parse_from_rfc3339(&e.timestamp)
818                        .map_or(true, |ts| ts.with_timezone(&chrono::Utc) >= baseline)
819            });
820            if let Some(ev) = matched {
821                return json_result(&serde_json::json!({
822                    "ok": true,
823                    "event": {
824                        "name": ev.name,
825                        "payload": ev.payload,
826                        "timestamp": ev.timestamp,
827                    },
828                    "elapsed_ms": start.elapsed().as_millis() as u64,
829                }));
830            }
831            if std::time::Instant::now() >= deadline {
832                return json_result(&serde_json::json!({
833                    "ok": false,
834                    "error": format!("timeout after {timeout_ms}ms waiting for event '{name}'"),
835                    "hint": "Ensure the app emits this Tauri event and Victauri captures it: \
836                             custom events need VictauriBuilder::listen_events(&[\"…\"]); \
837                             window-lifecycle events are captured automatically.",
838                    "elapsed_ms": start.elapsed().as_millis() as u64,
839                }));
840            }
841            tokio::time::sleep(poll).await;
842        }
843    }
844
845    #[tool(
846        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.",
847        annotations(
848            read_only_hint = true,
849            destructive_hint = false,
850            idempotent_hint = true,
851            open_world_hint = false
852        )
853    )]
854    async fn assert_semantic(
855        &self,
856        Parameters(params): Parameters<SemanticAssertParams>,
857    ) -> CallToolResult {
858        if !self.state.privacy.is_tool_enabled("eval_js") {
859            return tool_disabled("assert_semantic requires eval_js capability");
860        }
861        let code = format!("return ({})", params.expression);
862        let actual_json = match self
863            .eval_with_return(&code, params.webview_label.as_deref())
864            .await
865        {
866            Ok(result) => result,
867            Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
868        };
869
870        let actual: serde_json::Value = match serde_json::from_str(&actual_json) {
871            Ok(v) => v,
872            Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
873        };
874
875        let assertion = victauri_core::SemanticAssertion {
876            label: params.label,
877            condition: params.condition,
878            expected: params.expected,
879        };
880
881        let result = victauri_core::evaluate_assertion(actual, &assertion);
882        json_result(&result)
883    }
884
885    #[tool(
886        description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.",
887        annotations(
888            read_only_hint = true,
889            destructive_hint = false,
890            idempotent_hint = true,
891            open_world_hint = false
892        )
893    )]
894    async fn resolve_command(
895        &self,
896        Parameters(params): Parameters<ResolveCommandParams>,
897    ) -> CallToolResult {
898        let limit = params.limit.unwrap_or(5);
899        let mut results = self.state.registry.resolve(&params.query);
900        results.truncate(limit);
901        json_result(&results)
902    }
903
904    #[tool(
905        description = "List or search all registered Tauri commands with their argument schemas. Pass query to filter by name/description substring. Commands are registered via the #[inspectable] macro — apps that don't use it return names with null schemas; for those, use `introspect command_catalog` to recover real argument/result shapes from the live IPC log.",
906        annotations(
907            read_only_hint = true,
908            destructive_hint = false,
909            idempotent_hint = true,
910            open_world_hint = false
911        )
912    )]
913    async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
914        let commands = match params.query {
915            Some(q) => self.state.registry.search(&q),
916            None => self.state.registry.list(),
917        };
918        json_result(&commands)
919    }
920
921    #[tool(
922        description = "Read application-defined backend state via a registered probe. With no `probe`, lists available probe names. With a `probe` name, runs it and returns its JSON snapshot. Probes give first-class, discoverable access to domain state (e.g. a scoring pipeline's version + stale-item count, a queue's depth, cache stats) that would otherwise need query_db + log-grepping. Probes run in the Rust process with no IPC round-trip. Apps register them via VictauriBuilder::probe(name, closure).",
923        annotations(
924            read_only_hint = true,
925            destructive_hint = false,
926            idempotent_hint = true,
927            open_world_hint = false
928        )
929    )]
930    async fn app_state(&self, Parameters(params): Parameters<AppStateParams>) -> CallToolResult {
931        let Some(name) = params.probe else {
932            return json_result(&serde_json::json!({ "probes": self.state.probes.names() }));
933        };
934        if let Some(value) = self.state.probes.run(&name) {
935            json_result(&value)
936        } else {
937            let available = self.state.probes.names();
938            tool_error_with_hint(
939                format!(
940                    "unknown probe '{name}'. Available probes: {}",
941                    if available.is_empty() {
942                        "(none registered — add VictauriBuilder::probe(\"name\", ...))".to_string()
943                    } else {
944                        available.join(", ")
945                    }
946                ),
947                RecoveryHint::CheckInput,
948            )
949        }
950    }
951
952    #[tool(
953        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.",
954        annotations(
955            read_only_hint = true,
956            destructive_hint = false,
957            idempotent_hint = true,
958            open_world_hint = false
959        )
960    )]
961    async fn get_memory_stats(&self) -> CallToolResult {
962        let stats = crate::memory::current_stats();
963        json_result(&stats)
964    }
965
966    #[tool(
967        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.",
968        annotations(
969            read_only_hint = true,
970            destructive_hint = false,
971            idempotent_hint = true,
972            open_world_hint = false
973        )
974    )]
975    async fn get_plugin_info(&self) -> CallToolResult {
976        let disabled: Vec<&str> = self
977            .state
978            .privacy
979            .disabled_tools
980            .iter()
981            .map(std::string::String::as_str)
982            .collect();
983        let blocklist: Vec<&str> = self
984            .state
985            .privacy
986            .command_blocklist
987            .iter()
988            .map(std::string::String::as_str)
989            .collect();
990        let allowlist: Option<Vec<&str>> = self
991            .state
992            .privacy
993            .command_allowlist
994            .as_ref()
995            .map(|s| s.iter().map(std::string::String::as_str).collect());
996        let all_tools = Self::tool_router().list_all();
997        let enabled_tools: Vec<&str> = all_tools
998            .iter()
999            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
1000            .map(|t| t.name.as_ref())
1001            .collect();
1002
1003        // Host-app identity: lets an agent verify on its FIRST call that it reached the
1004        // intended app (not another Victauri instance sharing the discovery port).
1005        let app_cfg = self.bridge.tauri_config();
1006        let result = serde_json::json!({
1007            "version": env!("CARGO_PKG_VERSION"),
1008            "bridge_version": BRIDGE_VERSION,
1009            "port": self.state.port.load(Ordering::Relaxed),
1010            "app": {
1011                "identifier": app_cfg.get("identifier"),
1012                "product_name": app_cfg.get("product_name"),
1013            },
1014            "tools": {
1015                "total": all_tools.len(),
1016                "enabled": enabled_tools.len(),
1017                "enabled_list": enabled_tools,
1018                "disabled_list": disabled,
1019            },
1020            "commands": {
1021                "allowlist": allowlist,
1022                "blocklist": blocklist,
1023            },
1024            "privacy": {
1025                "profile": self.state.privacy.profile.to_string(),
1026                "redaction_enabled": self.state.privacy.redaction_enabled,
1027            },
1028            "capacities": {
1029                "event_log": self.state.event_log.capacity(),
1030                "eval_timeout_secs": self.state.eval_timeout.as_secs(),
1031            },
1032            "registered_commands": self.state.registry.count(),
1033            "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
1034            "uptime_secs": self.state.started_at.elapsed().as_secs(),
1035        });
1036        json_result(&result)
1037    }
1038
1039    #[tool(
1040        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.",
1041        annotations(
1042            read_only_hint = true,
1043            destructive_hint = false,
1044            idempotent_hint = true,
1045            open_world_hint = false
1046        )
1047    )]
1048    async fn get_diagnostics(
1049        &self,
1050        Parameters(params): Parameters<DiagnosticsParams>,
1051    ) -> CallToolResult {
1052        self.eval_bridge(
1053            "return window.__VICTAURI__?.getDiagnostics()",
1054            params.webview_label.as_deref(),
1055        )
1056        .await
1057    }
1058
1059    // ── Backend Access Tools ───────────────────────────────────────────────
1060
1061    #[tool(
1062        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.",
1063        annotations(
1064            read_only_hint = true,
1065            destructive_hint = false,
1066            idempotent_hint = true,
1067            open_world_hint = false
1068        )
1069    )]
1070    async fn app_info(&self) -> CallToolResult {
1071        let config = self.bridge.tauri_config();
1072
1073        let data_dir = self.bridge.app_data_dir().ok();
1074        let config_dir = self.bridge.app_config_dir().ok();
1075        let log_dir = self.bridge.app_log_dir().ok();
1076        let local_data_dir = self.bridge.app_local_data_dir().ok();
1077
1078        let env_vars: std::collections::BTreeMap<String, String> = std::env::vars()
1079            .filter(|(k, _)| is_safe_env_key(k))
1080            .collect();
1081
1082        // Enumerate every database candidate across ALL roots (configured db_search_paths
1083        // + every OS app dir), each tagged with size, whether it is a WebView/engine
1084        // internal store, and whether it is the one `query_db` would auto-select. This lets
1085        // an agent see and disambiguate the real app DB instead of guessing (audit /
1086        // red-team "wrong DB" finding — `app_info.databases` previously only walked
1087        // data_dir and returned bare relative names).
1088        #[cfg(feature = "sqlite")]
1089        let databases: Vec<serde_json::Value> = {
1090            let mut all_dirs: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
1091            for d in [
1092                data_dir.as_ref(),
1093                config_dir.as_ref(),
1094                log_dir.as_ref(),
1095                local_data_dir.as_ref(),
1096            ]
1097            .into_iter()
1098            .flatten()
1099            {
1100                all_dirs.push(d.clone());
1101            }
1102            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
1103                all_dirs.clone()
1104            } else {
1105                self.state.db_search_paths.clone()
1106            };
1107            let selected = crate::database::select_app_database(&select_dirs).ok();
1108            crate::database::classify_databases(&all_dirs)
1109                .into_iter()
1110                .map(|c| {
1111                    serde_json::json!({
1112                        "path": c.path.to_string_lossy(),
1113                        "size_bytes": c.size_bytes,
1114                        "webview_internal": c.webview_internal,
1115                        "selected": selected.as_ref() == Some(&c.path),
1116                    })
1117                })
1118                .collect()
1119        };
1120
1121        #[cfg(not(feature = "sqlite"))]
1122        let databases: Vec<serde_json::Value> = Vec::new();
1123
1124        let result = serde_json::json!({
1125            "config": config,
1126            "paths": {
1127                "data": data_dir.as_ref().map(|p| p.to_string_lossy()),
1128                "config": config_dir.as_ref().map(|p| p.to_string_lossy()),
1129                "log": log_dir.as_ref().map(|p| p.to_string_lossy()),
1130                "local_data": local_data_dir.as_ref().map(|p| p.to_string_lossy()),
1131            },
1132            "databases": databases,
1133            "env": env_vars,
1134            "process": {
1135                "pid": std::process::id(),
1136                "arch": std::env::consts::ARCH,
1137                "os": std::env::consts::OS,
1138                "family": std::env::consts::FAMILY,
1139            },
1140        });
1141        json_result(&result)
1142    }
1143
1144    #[tool(
1145        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.",
1146        annotations(
1147            read_only_hint = true,
1148            destructive_hint = false,
1149            idempotent_hint = true,
1150            open_world_hint = false
1151        )
1152    )]
1153    async fn list_app_dir(
1154        &self,
1155        Parameters(params): Parameters<ListAppDirParams>,
1156    ) -> CallToolResult {
1157        let base = match self.resolve_app_dir(params.directory) {
1158            Ok(d) => d,
1159            Err(e) => return tool_error(e),
1160        };
1161
1162        let target = if let Some(ref sub) = params.path {
1163            // Lexical traversal guard BEFORE the existence check: `safe_within`
1164            // canonicalizes (which errors on non-existent paths), so a `..` or
1165            // absolute sub-path must be rejected as traversal up front rather
1166            // than falling through to a misleading "does not exist" result.
1167            if let Err(e) = Self::lexical_safe(std::path::Path::new(sub)) {
1168                return tool_error(e);
1169            }
1170            let resolved = base.join(sub);
1171            // A missing directory is a normal, non-error result.
1172            if !resolved.exists() {
1173                return json_result(&serde_json::json!({
1174                    "base": base.to_string_lossy(),
1175                    "path": sub,
1176                    "exists": false,
1177                    "entries": [],
1178                    "count": 0,
1179                }));
1180            }
1181            if let Err(e) = Self::safe_within(&base, &resolved) {
1182                return tool_error(e);
1183            }
1184            resolved
1185        } else {
1186            base.clone()
1187        };
1188
1189        // A missing base directory is a normal, non-error result.
1190        if !target.exists() {
1191            return json_result(&serde_json::json!({
1192                "base": base.to_string_lossy(),
1193                "path": params.path.unwrap_or_default(),
1194                "exists": false,
1195                "entries": [],
1196                "count": 0,
1197            }));
1198        }
1199
1200        let max_depth = params.max_depth.unwrap_or(1).min(5);
1201        let pattern = params.pattern.as_deref();
1202        let mut entries = Vec::new();
1203
1204        Self::list_dir_recursive(&target, &base, 0, max_depth, pattern, &mut entries);
1205        let truncated = entries.len() >= MAX_DIR_ENTRIES;
1206
1207        json_result(&serde_json::json!({
1208            "base": base.to_string_lossy(),
1209            "path": params.path.unwrap_or_default(),
1210            "exists": true,
1211            "entries": entries,
1212            "count": entries.len(),
1213            "truncated": truncated,
1214        }))
1215    }
1216
1217    #[tool(
1218        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.",
1219        annotations(
1220            read_only_hint = true,
1221            destructive_hint = false,
1222            idempotent_hint = true,
1223            open_world_hint = false
1224        )
1225    )]
1226    async fn read_app_file(
1227        &self,
1228        Parameters(params): Parameters<ReadAppFileParams>,
1229    ) -> CallToolResult {
1230        let base = match self.resolve_app_dir(params.directory) {
1231            Ok(d) => d,
1232            Err(e) => return tool_error(e),
1233        };
1234
1235        // Lexical traversal guard FIRST — before the existence check — so a
1236        // traversal attempt (`..` / absolute) is rejected as traversal rather
1237        // than leaking whether the out-of-tree target exists via "file not
1238        // found". `safe_within` (which canonicalizes) stays below as
1239        // defense-in-depth for real files.
1240        if let Err(e) = Self::lexical_safe(std::path::Path::new(&params.path)) {
1241            return tool_error(e);
1242        }
1243        let target = base.join(&params.path);
1244        if !target.exists() {
1245            return tool_error(format!("file not found: {}", params.path));
1246        }
1247        if let Err(e) = Self::safe_within(&base, &target) {
1248            return tool_error(e);
1249        }
1250        if !target.is_file() {
1251            return tool_error(format!("not a file: {}", params.path));
1252        }
1253
1254        let max_bytes = params.max_bytes.unwrap_or(1_048_576).min(10_485_760);
1255
1256        // Open the CANONICAL validated path, and do the blocking file IO on the blocking pool.
1257        // Doing sync `std::fs` IO directly in this async fn could stall the executor thread if a
1258        // regular file were swapped (between the `safe_within` check and the open) for a FIFO or
1259        // slow device whose `read_to_end` blocks; opening the canonical path also closes the
1260        // trivial validate-lexical / open-lexical symlink-swap window.
1261        let canonical = match std::fs::canonicalize(&target) {
1262            Ok(c) => c,
1263            Err(e) => return tool_error(format!("cannot resolve path: {e}")),
1264        };
1265        #[allow(clippy::cast_possible_truncation)]
1266        let read = tokio::task::spawn_blocking(
1267            move || -> Result<(Vec<u8>, usize, Option<u64>), String> {
1268                use std::io::Read;
1269                let metadata = std::fs::metadata(&canonical).ok();
1270                let size = metadata.as_ref().map(|m| m.len() as usize);
1271                let modified = metadata.as_ref().and_then(|m| m.modified().ok()).map(|t| {
1272                    t.duration_since(std::time::SystemTime::UNIX_EPOCH)
1273                        .unwrap_or_default()
1274                        .as_secs()
1275                });
1276                // Bounded read (audit B7): pull at most max_bytes+1 instead of slurping the
1277                // whole file. The +1 detects truncation; the reported size comes from metadata.
1278                let f = std::fs::File::open(&canonical).map_err(|e| e.to_string())?;
1279                let mut buf = Vec::new();
1280                f.take(max_bytes as u64 + 1)
1281                    .read_to_end(&mut buf)
1282                    .map_err(|e| e.to_string())?;
1283                let reported = size.unwrap_or(buf.len());
1284                Ok((buf, reported, modified))
1285            },
1286        )
1287        .await;
1288        let (mut bytes, original_size, modified) = match read {
1289            Ok(Ok(v)) => v,
1290            Ok(Err(e)) => return tool_error(format!("failed to read file: {e}")),
1291            Err(e) => return tool_error(format!("file read task failed: {e}")),
1292        };
1293        let truncated = bytes.len() > max_bytes;
1294        if truncated {
1295            bytes.truncate(max_bytes);
1296        }
1297
1298        let file_info = serde_json::json!({
1299            "path": params.path,
1300            "size": original_size,
1301            "truncated": truncated,
1302            "modified": modified,
1303        });
1304
1305        if params.binary == Some(true) {
1306            use base64::Engine;
1307            let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1308            json_result(&serde_json::json!({
1309                "file": file_info,
1310                "encoding": "base64",
1311                "content": b64,
1312            }))
1313        } else {
1314            match String::from_utf8(bytes) {
1315                Ok(text) => json_result(&serde_json::json!({
1316                    "file": file_info,
1317                    "encoding": "utf-8",
1318                    "content": text,
1319                })),
1320                Err(e) => {
1321                    use base64::Engine;
1322                    let bytes = e.into_bytes();
1323                    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1324                    json_result(&serde_json::json!({
1325                        "file": file_info,
1326                        "encoding": "base64",
1327                        "note": "file is not valid UTF-8, returning base64",
1328                        "content": b64,
1329                    }))
1330                }
1331            }
1332        }
1333    }
1334
1335    #[tool(
1336        description = "Execute a bounded, read-only SQL query against a SQLite database in the app's data directory. The SQL goes in the `query` field (alias: `sql`). Auto-discovers database files if no path is specified. Only SELECT/PRAGMA/EXPLAIN/WITH queries are allowed. CPU time, cell size, row count, and returned bytes are capped. Returns rows as JSON objects with column names as keys. This provides direct backend database access without going through the webview or IPC.",
1337        annotations(
1338            read_only_hint = true,
1339            destructive_hint = false,
1340            idempotent_hint = true,
1341            open_world_hint = false
1342        )
1343    )]
1344    async fn query_db(&self, Parameters(params): Parameters<QueryDbParams>) -> CallToolResult {
1345        // query_db is ALWAYS registered as a tool so the rmcp `#[tool_router]` macro
1346        // compiles with `default-features = false` (a consumer that drops the heavy
1347        // rusqlite C dependency). The actual SQLite implementation only exists with the
1348        // `sqlite` feature; without it, return a clear, actionable error.
1349        #[cfg(feature = "sqlite")]
1350        {
1351            self.query_db_impl(params).await
1352        }
1353        #[cfg(not(feature = "sqlite"))]
1354        {
1355            let _ = params;
1356            tool_error(
1357                "query_db is unavailable: this build was compiled without the 'sqlite' \
1358                 feature (default-features = false). Re-enable the 'sqlite' feature to use it.",
1359            )
1360        }
1361    }
1362
1363    /// Real `query_db` implementation — compiled only with the `sqlite` feature.
1364    #[cfg(feature = "sqlite")]
1365    async fn query_db_impl(&self, params: QueryDbParams) -> CallToolResult {
1366        let data_dir = match self.bridge.app_data_dir() {
1367            Ok(d) => d,
1368            Err(e) => return tool_error(format!("cannot access app data directory: {e}")),
1369        };
1370
1371        let app_dirs: Vec<std::path::PathBuf> = [
1372            self.bridge.app_data_dir(),
1373            self.bridge.app_config_dir(),
1374            self.bridge.app_local_data_dir(),
1375            self.bridge.app_log_dir(),
1376        ]
1377        .into_iter()
1378        .filter_map(Result::ok)
1379        .collect::<std::collections::HashSet<_>>()
1380        .into_iter()
1381        .collect();
1382        // Explicitly-configured roots (VictauriBuilder::db_search_paths) take
1383        // precedence over OS app directories for auto-discovery, so a configured
1384        // application DB wins over incidental ones (e.g. WebView internals).
1385        let mut search_dirs: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
1386        search_dirs.extend(app_dirs);
1387
1388        let db_path = if let Some(ref requested_path) = params.path {
1389            match Self::resolve_existing_db_path(&search_dirs, requested_path) {
1390                Ok(path) => path,
1391                Err(e) => return tool_error(e),
1392            }
1393        } else {
1394            // Auto-select the application DB. When db_search_paths is configured it is
1395            // EXCLUSIVE — never fall back to OS app dirs (which hold WebView internals),
1396            // so a configured-but-empty root yields a clear error instead of silently
1397            // querying the wrong database. WebView/browser-engine internal stores are
1398            // excluded and the largest remaining candidate wins (audit / red-team "wrong
1399            // DB" finding).
1400            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
1401                search_dirs.clone()
1402            } else {
1403                self.state.db_search_paths.clone()
1404            };
1405            match crate::database::select_app_database(&select_dirs) {
1406                Ok(p) => p,
1407                Err(e) => return tool_error(e),
1408            }
1409        };
1410
1411        let db_display = db_path
1412            .strip_prefix(&data_dir)
1413            .unwrap_or(&db_path)
1414            .to_string_lossy()
1415            .into_owned();
1416        let bind_params = params.params.unwrap_or_default();
1417        let query = params.query;
1418        let max_rows = params.max_rows;
1419
1420        match tokio::task::spawn_blocking(move || {
1421            crate::database::query(&db_path, &query, &bind_params, max_rows)
1422        })
1423        .await
1424        {
1425            Ok(Ok(mut result)) => {
1426                if let Some(obj) = result.as_object_mut() {
1427                    obj.insert("database".to_string(), serde_json::json!(db_display));
1428                }
1429                json_result(&result)
1430            }
1431            Ok(Err(e)) => tool_error(e),
1432            Err(e) => tool_error(format!("database query task failed: {e}")),
1433        }
1434    }
1435
1436    // ── Compound Tools ──────────────────────────────────────────────────────
1437
1438    #[tool(
1439        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.",
1440        annotations(
1441            read_only_hint = false,
1442            destructive_hint = false,
1443            idempotent_hint = false,
1444            open_world_hint = false
1445        )
1446    )]
1447    async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
1448        if !self.state.privacy.is_tool_enabled("interact") {
1449            return tool_disabled("interact");
1450        }
1451        match params.action {
1452            InteractAction::Click => {
1453                if !self.state.privacy.is_tool_enabled("interact.click") {
1454                    return tool_disabled("interact.click");
1455                }
1456                let Some(ref_id) = &params.ref_id else {
1457                    return missing_param("ref_id", "click");
1458                };
1459                if params.trusted.unwrap_or(false) {
1460                    // Resolve the element's viewport-center coords, run the
1461                    // actionability check, then deliver a real OS click.
1462                    let probe = format!(
1463                        "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); \
1464                         if(!__e) return null; __e.scrollIntoView({{block:'center',inline:'center',behavior:'instant'}}); \
1465                         var __b=__e.getBoundingClientRect(); \
1466                         return {{x:__b.left+__b.width/2, y:__b.top+__b.height/2}}",
1467                        js_string(ref_id)
1468                    );
1469                    let raw = match self
1470                        .eval_with_return(&probe, params.webview_label.as_deref())
1471                        .await
1472                    {
1473                        Ok(r) => r,
1474                        Err(e) => return tool_error(e),
1475                    };
1476                    let Ok(point) = serde_json::from_str::<serde_json::Value>(&raw) else {
1477                        return tool_error_with_hint(
1478                            format!("ref not found: {ref_id}"),
1479                            RecoveryHint::CheckInput,
1480                        );
1481                    };
1482                    let (Some(x), Some(y)) = (
1483                        point.get("x").and_then(serde_json::Value::as_f64),
1484                        point.get("y").and_then(serde_json::Value::as_f64),
1485                    ) else {
1486                        return tool_error_with_hint(
1487                            format!("ref not found: {ref_id}"),
1488                            RecoveryHint::CheckInput,
1489                        );
1490                    };
1491                    let bridge = self.bridge.clone();
1492                    let label = params.webview_label.clone();
1493                    let native = tokio::task::spawn_blocking(move || {
1494                        bridge.native_click(label.as_deref(), x, y)
1495                    })
1496                    .await
1497                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1498                    return match native {
1499                        Ok(()) => json_result(
1500                            &serde_json::json!({"ok": true, "trusted": true, "x": x, "y": y}),
1501                        ),
1502                        Err(e) => tool_error(e),
1503                    };
1504                }
1505                let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
1506                self.eval_bridge(&code, params.webview_label.as_deref())
1507                    .await
1508            }
1509            InteractAction::DoubleClick => {
1510                if !self.state.privacy.is_tool_enabled("interact.double_click") {
1511                    return tool_disabled("interact.double_click");
1512                }
1513                let Some(ref_id) = &params.ref_id else {
1514                    return missing_param("ref_id", "double_click");
1515                };
1516                let code = format!(
1517                    "return window.__VICTAURI__?.doubleClick({})",
1518                    js_string(ref_id)
1519                );
1520                self.eval_bridge(&code, params.webview_label.as_deref())
1521                    .await
1522            }
1523            InteractAction::Hover => {
1524                if !self.state.privacy.is_tool_enabled("interact.hover") {
1525                    return tool_disabled("interact.hover");
1526                }
1527                let Some(ref_id) = &params.ref_id else {
1528                    return missing_param("ref_id", "hover");
1529                };
1530                let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
1531                self.eval_bridge(&code, params.webview_label.as_deref())
1532                    .await
1533            }
1534            InteractAction::Focus => {
1535                if !self.state.privacy.is_tool_enabled("interact.focus") {
1536                    return tool_disabled("interact.focus");
1537                }
1538                let Some(ref_id) = &params.ref_id else {
1539                    return missing_param("ref_id", "focus");
1540                };
1541                let code = format!(
1542                    "return window.__VICTAURI__?.focusElement({})",
1543                    js_string(ref_id)
1544                );
1545                self.eval_bridge(&code, params.webview_label.as_deref())
1546                    .await
1547            }
1548            InteractAction::ScrollIntoView => {
1549                if !self
1550                    .state
1551                    .privacy
1552                    .is_tool_enabled("interact.scroll_into_view")
1553                {
1554                    return tool_disabled("interact.scroll_into_view");
1555                }
1556                let ref_arg = params
1557                    .ref_id
1558                    .as_ref()
1559                    .map_or_else(|| "null".to_string(), |r| js_string(r));
1560                let x = params.x.unwrap_or(0.0);
1561                let y = params.y.unwrap_or(0.0);
1562                let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
1563                self.eval_bridge(&code, params.webview_label.as_deref())
1564                    .await
1565            }
1566            InteractAction::SelectOption => {
1567                if !self.state.privacy.is_tool_enabled("interact.select_option") {
1568                    return tool_disabled("interact.select_option");
1569                }
1570                let Some(ref_id) = &params.ref_id else {
1571                    return missing_param("ref_id", "select_option");
1572                };
1573                let values_vec;
1574                let values: &[String] = match (&params.values, &params.value) {
1575                    (Some(v), _) => v,
1576                    (None, Some(v)) => {
1577                        values_vec = vec![v.clone()];
1578                        &values_vec
1579                    }
1580                    (None, None) => &[],
1581                };
1582                let values_json =
1583                    serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
1584                let code = format!(
1585                    "return window.__VICTAURI__?.selectOption({}, {})",
1586                    js_string(ref_id),
1587                    values_json
1588                );
1589                self.eval_bridge(&code, params.webview_label.as_deref())
1590                    .await
1591            }
1592        }
1593    }
1594
1595    #[tool(
1596        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.",
1597        annotations(
1598            read_only_hint = false,
1599            destructive_hint = false,
1600            idempotent_hint = false,
1601            open_world_hint = false
1602        )
1603    )]
1604    async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
1605        match params.action {
1606            InputAction::Fill => {
1607                if !self.state.privacy.is_tool_enabled("fill") {
1608                    return tool_disabled("fill");
1609                }
1610                let Some(ref_id) = &params.ref_id else {
1611                    return missing_param("ref_id", "fill");
1612                };
1613                let Some(value) = &params.value else {
1614                    return missing_param("value", "fill");
1615                };
1616                let code = format!(
1617                    "return window.__VICTAURI__?.fill({}, {})",
1618                    js_string(ref_id),
1619                    js_string(value)
1620                );
1621                self.eval_bridge(&code, params.webview_label.as_deref())
1622                    .await
1623            }
1624            InputAction::TypeText => {
1625                if !self.state.privacy.is_tool_enabled("type_text") {
1626                    return tool_disabled("type_text");
1627                }
1628                let Some(ref_id) = &params.ref_id else {
1629                    return missing_param("ref_id", "type_text");
1630                };
1631                let Some(text) = &params.text else {
1632                    return missing_param("text", "type_text");
1633                };
1634                if params.trusted.unwrap_or(false) {
1635                    // Focus the element via JS, then deliver real OS keystrokes
1636                    // (isTrusted: true) for handlers that reject synthetic events.
1637                    let focus = format!(
1638                        "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); if(__e){{__e.focus();}} return !!__e",
1639                        js_string(ref_id)
1640                    );
1641                    let focused = self
1642                        .eval_with_return(&focus, params.webview_label.as_deref())
1643                        .await
1644                        .unwrap_or_default();
1645                    if focused != "true" {
1646                        return tool_error_with_hint(
1647                            format!("ref not found or not focusable: {ref_id}"),
1648                            RecoveryHint::CheckInput,
1649                        );
1650                    }
1651                    let bridge = self.bridge.clone();
1652                    let label = params.webview_label.clone();
1653                    let text = text.to_string();
1654                    let native = tokio::task::spawn_blocking(move || {
1655                        bridge.native_type_text(label.as_deref(), &text)
1656                    })
1657                    .await
1658                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1659                    return match native {
1660                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1661                        Err(e) => tool_error(e),
1662                    };
1663                }
1664                let code = format!(
1665                    "return window.__VICTAURI__?.type({}, {})",
1666                    js_string(ref_id),
1667                    js_string(text)
1668                );
1669                self.eval_bridge(&code, params.webview_label.as_deref())
1670                    .await
1671            }
1672            InputAction::PressKey => {
1673                if !self.state.privacy.is_tool_enabled("input.press_key") {
1674                    return tool_disabled("input.press_key");
1675                }
1676                let Some(key) = &params.key else {
1677                    return missing_param("key", "press_key");
1678                };
1679                if params.trusted.unwrap_or(false) {
1680                    // Optionally focus a target element, then send a real OS key.
1681                    if let Some(ref_id) = &params.ref_id {
1682                        let focus = format!(
1683                            "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); if(__e){{__e.focus();}} return !!__e",
1684                            js_string(ref_id)
1685                        );
1686                        let _ = self
1687                            .eval_with_return(&focus, params.webview_label.as_deref())
1688                            .await;
1689                    }
1690                    let bridge = self.bridge.clone();
1691                    let label = params.webview_label.clone();
1692                    let key = key.to_string();
1693                    let native = tokio::task::spawn_blocking(move || {
1694                        bridge.native_key(label.as_deref(), &key)
1695                    })
1696                    .await
1697                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1698                    return match native {
1699                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1700                        Err(e) => tool_error(e),
1701                    };
1702                }
1703                let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
1704                self.eval_bridge(&code, params.webview_label.as_deref())
1705                    .await
1706            }
1707        }
1708    }
1709
1710    #[tool(
1711        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).",
1712        annotations(
1713            read_only_hint = false,
1714            destructive_hint = false,
1715            idempotent_hint = true,
1716            open_world_hint = false
1717        )
1718    )]
1719    async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
1720        match params.action {
1721            WindowAction::GetState => {
1722                let states = self.bridge.get_window_states(params.label.as_deref());
1723                // A specific label that matches no window is an error, not an
1724                // empty array (which reads as "success, no state").
1725                if states.is_empty()
1726                    && let Some(label) = params.label.as_deref()
1727                {
1728                    return tool_error(format!(
1729                        "window not found: '{label}' (use window.list to see available labels)"
1730                    ));
1731                }
1732                json_result(&states)
1733            }
1734            WindowAction::List => {
1735                let labels = self.bridge.list_window_labels();
1736                json_result(&labels)
1737            }
1738            WindowAction::Introspectability => self.window_introspectability().await,
1739            WindowAction::Manage => {
1740                if !self.state.privacy.is_tool_enabled("window.manage") {
1741                    return tool_disabled("window.manage");
1742                }
1743                let Some(manage_action) = &params.manage_action else {
1744                    return missing_param("manage_action", "manage");
1745                };
1746                match self
1747                    .bridge
1748                    .manage_window(params.label.as_deref(), manage_action.as_str())
1749                {
1750                    Ok(msg) => CallToolResult::success(vec![Content::text(msg)]),
1751                    Err(e) => tool_error(e),
1752                }
1753            }
1754            WindowAction::Resize => {
1755                if !self.state.privacy.is_tool_enabled("window.resize") {
1756                    return tool_disabled("window.resize");
1757                }
1758                let Some(width) = params.width else {
1759                    return missing_param("width", "resize");
1760                };
1761                let Some(height) = params.height else {
1762                    return missing_param("height", "resize");
1763                };
1764                if width == 0 || height == 0 {
1765                    return tool_error_with_hint(
1766                        format!(
1767                            "invalid window size {width}x{height}: width and height must be > 0"
1768                        ),
1769                        RecoveryHint::CheckInput,
1770                    );
1771                }
1772                match self
1773                    .bridge
1774                    .resize_window(params.label.as_deref(), width, height)
1775                {
1776                    Ok(()) => {
1777                        let result =
1778                            serde_json::json!({"ok": true, "width": width, "height": height});
1779                        CallToolResult::success(vec![Content::text(result.to_string())])
1780                    }
1781                    Err(e) => tool_error(e),
1782                }
1783            }
1784            WindowAction::MoveTo => {
1785                if !self.state.privacy.is_tool_enabled("window.move_to") {
1786                    return tool_disabled("window.move_to");
1787                }
1788                let Some(x) = params.x else {
1789                    return missing_param("x", "move_to");
1790                };
1791                let Some(y) = params.y else {
1792                    return missing_param("y", "move_to");
1793                };
1794                match self.bridge.move_window(params.label.as_deref(), x, y) {
1795                    Ok(()) => {
1796                        let result = serde_json::json!({"ok": true, "x": x, "y": y});
1797                        CallToolResult::success(vec![Content::text(result.to_string())])
1798                    }
1799                    Err(e) => tool_error(e),
1800                }
1801            }
1802            WindowAction::SetTitle => {
1803                if !self.state.privacy.is_tool_enabled("window.set_title") {
1804                    return tool_disabled("window.set_title");
1805                }
1806                let Some(title) = &params.title else {
1807                    return missing_param("title", "set_title");
1808                };
1809                match self.bridge.set_window_title(params.label.as_deref(), title) {
1810                    Ok(()) => {
1811                        let result = serde_json::json!({"ok": true, "title": title});
1812                        CallToolResult::success(vec![Content::text(result.to_string())])
1813                    }
1814                    Err(e) => tool_error(e),
1815                }
1816            }
1817        }
1818    }
1819
1820    #[tool(
1821        description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
1822        annotations(
1823            read_only_hint = false,
1824            destructive_hint = true,
1825            idempotent_hint = false,
1826            open_world_hint = false
1827        )
1828    )]
1829    async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
1830        match params.action {
1831            StorageAction::Get => {
1832                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1833                    StorageType::Session => "getSessionStorage",
1834                    StorageType::Local => "getLocalStorage",
1835                };
1836                let key_arg = params
1837                    .key
1838                    .as_ref()
1839                    .map(|k| js_string(k))
1840                    .unwrap_or_default();
1841                let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
1842                self.eval_bridge(&code, params.webview_label.as_deref())
1843                    .await
1844            }
1845            StorageAction::Set => {
1846                if !self.state.privacy.is_tool_enabled("set_storage") {
1847                    return tool_disabled("set_storage");
1848                }
1849                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1850                    StorageType::Session => "setSessionStorage",
1851                    StorageType::Local => "setLocalStorage",
1852                };
1853                let Some(key) = &params.key else {
1854                    return missing_param("key", "set");
1855                };
1856                // Operator-protected keys (auth/role/tier/flags) can't be poisoned
1857                // via storage.set (audit #33).
1858                if !self.state.privacy.is_storage_key_allowed(key) {
1859                    return tool_error(format!(
1860                        "storage key '{key}' is protected by privacy configuration"
1861                    ));
1862                }
1863                let value = params
1864                    .value
1865                    .as_ref()
1866                    .cloned()
1867                    .unwrap_or(serde_json::Value::Null);
1868                let value_json =
1869                    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1870                let code = format!(
1871                    "return window.__VICTAURI__?.{method}({}, {value_json})",
1872                    js_string(key)
1873                );
1874                self.eval_bridge(&code, params.webview_label.as_deref())
1875                    .await
1876            }
1877            StorageAction::Delete => {
1878                if !self.state.privacy.is_tool_enabled("delete_storage") {
1879                    return tool_disabled("delete_storage");
1880                }
1881                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1882                    StorageType::Session => "deleteSessionStorage",
1883                    StorageType::Local => "deleteLocalStorage",
1884                };
1885                let Some(key) = &params.key else {
1886                    return missing_param("key", "delete");
1887                };
1888                let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
1889                self.eval_bridge(&code, params.webview_label.as_deref())
1890                    .await
1891            }
1892            StorageAction::GetCookies => {
1893                self.eval_bridge(
1894                    "return window.__VICTAURI__?.getCookies()",
1895                    params.webview_label.as_deref(),
1896                )
1897                .await
1898            }
1899        }
1900    }
1901
1902    #[tool(
1903        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.",
1904        annotations(
1905            read_only_hint = false,
1906            destructive_hint = false,
1907            idempotent_hint = false,
1908            open_world_hint = false
1909        )
1910    )]
1911    async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
1912        match params.action {
1913            NavigateAction::GoTo => {
1914                if !self.state.privacy.is_tool_enabled("navigate") {
1915                    return tool_disabled("navigate");
1916                }
1917                let Some(url) = &params.url else {
1918                    return missing_param("url", "go_to");
1919                };
1920                if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
1921                    return tool_error(e);
1922                }
1923                let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
1924                self.eval_bridge(&code, params.webview_label.as_deref())
1925                    .await
1926            }
1927            NavigateAction::GoBack => {
1928                self.eval_bridge(
1929                    "return window.__VICTAURI__?.navigateBack()",
1930                    params.webview_label.as_deref(),
1931                )
1932                .await
1933            }
1934            NavigateAction::GetHistory => {
1935                self.eval_bridge(
1936                    "return window.__VICTAURI__?.getNavigationLog()",
1937                    params.webview_label.as_deref(),
1938                )
1939                .await
1940            }
1941            NavigateAction::SetDialogResponse => {
1942                if !self.state.privacy.is_tool_enabled("set_dialog_response") {
1943                    return tool_disabled("set_dialog_response");
1944                }
1945                let Some(dialog_type) = params.dialog_type else {
1946                    return missing_param("dialog_type", "set_dialog_response");
1947                };
1948                let Some(dialog_action) = params.dialog_action else {
1949                    return missing_param("dialog_action", "set_dialog_response");
1950                };
1951                let text_arg = params
1952                    .text
1953                    .as_ref()
1954                    .map_or_else(|| "undefined".to_string(), |t| js_string(t));
1955                let code = format!(
1956                    "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})",
1957                    js_string(dialog_type.as_str()),
1958                    js_string(dialog_action.as_str())
1959                );
1960                self.eval_bridge(&code, params.webview_label.as_deref())
1961                    .await
1962            }
1963            NavigateAction::GetDialogLog => {
1964                self.eval_bridge(
1965                    "return window.__VICTAURI__?.getDialogLog()",
1966                    params.webview_label.as_deref(),
1967                )
1968                .await
1969            }
1970        }
1971    }
1972
1973    #[tool(
1974        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).",
1975        annotations(
1976            read_only_hint = false,
1977            destructive_hint = false,
1978            idempotent_hint = false,
1979            open_world_hint = false
1980        )
1981    )]
1982    async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
1983        const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
1984        if !self.state.privacy.is_tool_enabled("recording") {
1985            return tool_disabled("recording");
1986        }
1987        match params.action {
1988            RecordingAction::Start => {
1989                let session_id = params
1990                    .session_id
1991                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1992                match self.state.recorder.start(session_id.clone()) {
1993                    Ok(()) => {
1994                        let result = serde_json::json!({
1995                            "started": true,
1996                            "session_id": session_id,
1997                        });
1998                        CallToolResult::success(vec![Content::text(result.to_string())])
1999                    }
2000                    Err(e) => tool_error(e.to_string()),
2001                }
2002            }
2003            RecordingAction::Stop => match self.state.recorder.stop() {
2004                Some(session) => json_result(&session),
2005                None => tool_error("no recording is active"),
2006            },
2007            RecordingAction::Checkpoint => {
2008                // checkpoint_id is optional — auto-generate a short id when the
2009                // caller just wants a positional marker. The id is echoed back in
2010                // the response so it can be referenced later
2011                // (events_between_checkpoints / replay).
2012                let id = params
2013                    .checkpoint_id
2014                    .unwrap_or_else(|| format!("cp-{}", uuid::Uuid::new_v4()));
2015                let state = params.state.unwrap_or(serde_json::Value::Null);
2016                match self
2017                    .state
2018                    .recorder
2019                    .checkpoint(id.clone(), params.checkpoint_label, state)
2020                {
2021                    Ok(()) => {
2022                        let result = serde_json::json!({
2023                            "created": true,
2024                            "checkpoint_id": id,
2025                            "event_index": self.state.recorder.event_count(),
2026                        });
2027                        CallToolResult::success(vec![Content::text(result.to_string())])
2028                    }
2029                    Err(e) => tool_error(e.to_string()),
2030                }
2031            }
2032            RecordingAction::ListCheckpoints => {
2033                let checkpoints = self.state.recorder.get_checkpoints();
2034                json_result(&checkpoints)
2035            }
2036            RecordingAction::GetEvents => {
2037                let events = self
2038                    .state
2039                    .recorder
2040                    .events_since(params.since_index.unwrap_or(0));
2041                json_result(&events)
2042            }
2043            RecordingAction::EventsBetween => {
2044                let Some(from) = &params.from else {
2045                    return missing_param("from", "events_between");
2046                };
2047                let Some(to) = &params.to else {
2048                    return missing_param("to", "events_between");
2049                };
2050                match self.state.recorder.events_between_checkpoints(from, to) {
2051                    Ok(events) => json_result(&events),
2052                    Err(e) => tool_error(e.to_string()),
2053                }
2054            }
2055            RecordingAction::GetReplay => {
2056                let calls = self.state.recorder.ipc_replay_sequence();
2057                json_result(&calls)
2058            }
2059            RecordingAction::Export => match self.state.recorder.export() {
2060                Some(s) => {
2061                    let json = serde_json::to_string_pretty(&s)
2062                        .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
2063                    CallToolResult::success(vec![Content::text(json)])
2064                }
2065                None => tool_error("no recording is active — start one first"),
2066            },
2067            RecordingAction::Import => {
2068                let Some(session_json) = &params.session_json else {
2069                    return missing_param("session_json", "import");
2070                };
2071                if session_json.len() > MAX_SESSION_JSON {
2072                    return tool_error("session JSON exceeds maximum size (10 MB)");
2073                }
2074                let session: victauri_core::RecordedSession =
2075                    match serde_json::from_str(session_json) {
2076                        Ok(s) => s,
2077                        Err(e) => return tool_error(format!("invalid session JSON: {e}")),
2078                    };
2079
2080                let result = serde_json::json!({
2081                    "imported": true,
2082                    "session_id": session.id,
2083                    "event_count": session.events.len(),
2084                    "checkpoint_count": session.checkpoints.len(),
2085                    "started_at": session.started_at.to_rfc3339(),
2086                });
2087                self.state.recorder.import(session);
2088                CallToolResult::success(vec![Content::text(result.to_string())])
2089            }
2090            RecordingAction::Flush => {
2091                if !self.state.recorder.is_recording() {
2092                    return tool_error("no active recording — start a recording first");
2093                }
2094                let code = "return window.__VICTAURI__?.getEventStream(0)";
2095                match self
2096                    .eval_with_return(code, params.webview_label.as_deref())
2097                    .await
2098                {
2099                    Ok(result_str) => {
2100                        let events: Vec<serde_json::Value> =
2101                            serde_json::from_str(&result_str).unwrap_or_default();
2102                        let mut count = 0u64;
2103                        for ev in &events {
2104                            if let Some(app_event) = crate::mcp::server::parse_bridge_event(ev) {
2105                                self.state.event_log.push(app_event.clone());
2106                                self.state.recorder.record_event(app_event);
2107                                count += 1;
2108                            }
2109                        }
2110                        json_result(&serde_json::json!({
2111                            "flushed": true,
2112                            "events_captured": count,
2113                        }))
2114                    }
2115                    Err(e) => tool_error(format!("flush failed: {e}")),
2116                }
2117            }
2118            RecordingAction::Replay => {
2119                let calls = self.state.recorder.ipc_replay_sequence();
2120                if calls.is_empty() {
2121                    return tool_error("no IPC calls recorded — record a session first");
2122                }
2123                let mut replay_results = Vec::new();
2124                for call in &calls {
2125                    // Enforce the same command allow/blocklist as invoke_command
2126                    // (audit #30/#31): a recorded/imported session must not be able to
2127                    // invoke a command an operator blocked.
2128                    if !self.state.privacy.is_invoke_allowed(&call.command)
2129                        || !self.state.privacy.is_command_allowed(&call.command)
2130                    {
2131                        replay_results.push(serde_json::json!({
2132                            "command": call.command,
2133                            "status": "blocked",
2134                            "error": "blocked by privacy configuration",
2135                        }));
2136                        continue;
2137                    }
2138                    let code = format!(
2139                        "return window.__TAURI_INTERNALS__.invoke({})",
2140                        js_string(&call.command)
2141                    );
2142                    let outcome = match self
2143                        .eval_with_return(&code, params.webview_label.as_deref())
2144                        .await
2145                    {
2146                        Ok(result_str) => {
2147                            let value: serde_json::Value = serde_json::from_str(&result_str)
2148                                .unwrap_or(serde_json::Value::String(result_str));
2149                            let shape = crate::introspection::JsonShape::from_value(&value);
2150                            serde_json::json!({
2151                                "command": call.command,
2152                                "status": "ok",
2153                                "response_type": shape.type_name(),
2154                            })
2155                        }
2156                        Err(e) => {
2157                            serde_json::json!({
2158                                "command": call.command,
2159                                "status": "error",
2160                                "error": e,
2161                            })
2162                        }
2163                    };
2164                    replay_results.push(outcome);
2165                }
2166                let passed = replay_results
2167                    .iter()
2168                    .filter(|r| r.get("status").and_then(|s| s.as_str()) == Some("ok"))
2169                    .count();
2170                let result = serde_json::json!({
2171                    "replayed": replay_results.len(),
2172                    "passed": passed,
2173                    "failed": replay_results.len() - passed,
2174                    "results": replay_results,
2175                });
2176                json_result(&result)
2177            }
2178        }
2179    }
2180
2181    #[tool(
2182        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).",
2183        annotations(
2184            read_only_hint = true,
2185            destructive_hint = false,
2186            idempotent_hint = true,
2187            open_world_hint = false
2188        )
2189    )]
2190    async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
2191        match params.action {
2192            InspectAction::GetStyles => {
2193                let Some(ref_id) = &params.ref_id else {
2194                    return missing_param("ref_id", "get_styles");
2195                };
2196                let props_arg = match &params.properties {
2197                    Some(props) => {
2198                        let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
2199                        format!("[{}]", arr.join(","))
2200                    }
2201                    None => "null".to_string(),
2202                };
2203                let code = format!(
2204                    "return window.__VICTAURI__?.getStyles({}, {})",
2205                    js_string(ref_id),
2206                    props_arg
2207                );
2208                self.eval_bridge(&code, params.webview_label.as_deref())
2209                    .await
2210            }
2211            InspectAction::GetBoundingBoxes => {
2212                let Some(ref_ids) = &params.ref_ids else {
2213                    return missing_param("ref_ids", "get_bounding_boxes");
2214                };
2215                let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
2216                let code = format!(
2217                    "return window.__VICTAURI__?.getBoundingBoxes([{}])",
2218                    refs.join(",")
2219                );
2220                self.eval_bridge(&code, params.webview_label.as_deref())
2221                    .await
2222            }
2223            InspectAction::Highlight => {
2224                // highlight injects a debug overlay node into the page — a DOM
2225                // mutation — so it is gated separately and excluded from the
2226                // read-only Observe profile (red-team P1).
2227                if !self.state.privacy.is_tool_enabled("inspect.highlight") {
2228                    return tool_disabled("inspect.highlight");
2229                }
2230                let Some(ref_id) = &params.ref_id else {
2231                    return missing_param("ref_id", "highlight");
2232                };
2233                let color_arg = match &params.color {
2234                    Some(c) => match sanitize_css_color(c) {
2235                        Ok(safe) => format!("\"{safe}\""),
2236                        Err(e) => return tool_error(e),
2237                    },
2238                    None => "null".to_string(),
2239                };
2240                let label_arg = match &params.label {
2241                    Some(l) => js_string(l),
2242                    None => "null".to_string(),
2243                };
2244                let code = format!(
2245                    "return window.__VICTAURI__?.highlightElement({}, {}, {})",
2246                    js_string(ref_id),
2247                    color_arg,
2248                    label_arg
2249                );
2250                self.eval_bridge(&code, params.webview_label.as_deref())
2251                    .await
2252            }
2253            InspectAction::ClearHighlights => {
2254                if !self
2255                    .state
2256                    .privacy
2257                    .is_tool_enabled("inspect.clear_highlights")
2258                {
2259                    return tool_disabled("inspect.clear_highlights");
2260                }
2261                self.eval_bridge(
2262                    "return window.__VICTAURI__?.clearHighlights()",
2263                    params.webview_label.as_deref(),
2264                )
2265                .await
2266            }
2267            InspectAction::AuditAccessibility => {
2268                self.eval_bridge(
2269                    "return window.__VICTAURI__?.auditAccessibility()",
2270                    params.webview_label.as_deref(),
2271                )
2272                .await
2273            }
2274            InspectAction::GetPerformance => {
2275                self.eval_bridge(
2276                    "return window.__VICTAURI__?.getPerformanceMetrics()",
2277                    params.webview_label.as_deref(),
2278                )
2279                .await
2280            }
2281        }
2282    }
2283
2284    #[tool(
2285        description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
2286        annotations(
2287            read_only_hint = false,
2288            destructive_hint = false,
2289            idempotent_hint = true,
2290            open_world_hint = false
2291        )
2292    )]
2293    async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
2294        match params.action {
2295            CssAction::Inject => {
2296                if !self.state.privacy.is_tool_enabled("inject_css") {
2297                    return tool_disabled("inject_css");
2298                }
2299                let Some(css) = &params.css else {
2300                    return missing_param("css", "inject");
2301                };
2302                // Block remote @import / url(...) exfil vectors unless explicitly opted in.
2303                if let Err(e) = sanitize_injected_css(css, params.allow_remote) {
2304                    return tool_error(e);
2305                }
2306                let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
2307                self.eval_bridge(&code, params.webview_label.as_deref())
2308                    .await
2309            }
2310            CssAction::Remove => {
2311                if !self.state.privacy.is_tool_enabled("css.remove") {
2312                    return tool_disabled("css.remove");
2313                }
2314                self.eval_bridge(
2315                    "return window.__VICTAURI__?.removeInjectedCss()",
2316                    params.webview_label.as_deref(),
2317                )
2318                .await
2319            }
2320        }
2321    }
2322
2323    #[tool(
2324        description = "Network request interception (Playwright route() equivalent, no CDP). \
2325            Matches webview fetch/XHR by URL and blocks, mocks, or delays them. \
2326            Actions:\n\
2327            - `add`: add a rule. `pattern` (+ optional `match_type`: substring/glob/regex/exact, \
2328              and `method`) selects requests; `behavior` is `block` (abort), `fulfill` (return a \
2329              mock `status`/`headers`/`body`/`content_type`), or `delay` (proceed after `delay_ms`). \
2330              `times` limits how often it fires. Rules are page-scoped (cleared on reload).\n\
2331            - `list`: list active rules.\n\
2332            - `clear` (by `id`) / `clear_all`: remove rules.\n\
2333            - `matches`: log of intercepted requests.\n\
2334            Note: fetch supports all behaviors; XHR supports block/delay (fulfill is fetch-only). \
2335            Top-level navigation, sub-resource (img/css), and WebSocket traffic are not intercepted. \
2336            Tauri IPC (ipc.localhost) is OBSERVE-ONLY: such calls appear in `matches`, but block/\
2337            fulfill/delay do NOT take effect on them — Tauri serves IPC below the JS fetch layer, so \
2338            it cannot be controlled cross-platform without CDP. There is no IPC-control tool; the \
2339            `fault` tool only affects commands you drive via `invoke_command`, not real user IPC.",
2340        annotations(
2341            read_only_hint = false,
2342            destructive_hint = false,
2343            idempotent_hint = false,
2344            open_world_hint = false
2345        )
2346    )]
2347    async fn route(&self, Parameters(params): Parameters<RouteParams>) -> CallToolResult {
2348        match params.action {
2349            RouteAction::Add => {
2350                if !self.state.privacy.is_tool_enabled("route.add") {
2351                    return tool_disabled("route.add");
2352                }
2353                let Some(pattern) = &params.pattern else {
2354                    return missing_param("pattern", "add");
2355                };
2356                let behavior = params.behavior.unwrap_or(RouteBehavior::Fulfill);
2357                let match_type = params.match_type.unwrap_or(RouteMatchType::Substring);
2358                let mut rule = serde_json::json!({
2359                    "pattern": pattern,
2360                    "match_type": match_type.as_str(),
2361                    "action": behavior.as_str(),
2362                });
2363                if let Some(m) = &params.method {
2364                    rule["method"] = serde_json::json!(m);
2365                }
2366                if let Some(s) = params.status {
2367                    rule["status"] = serde_json::json!(s);
2368                }
2369                if let Some(st) = &params.status_text {
2370                    rule["status_text"] = serde_json::json!(st);
2371                }
2372                if let Some(h) = &params.headers {
2373                    rule["headers"] = h.clone();
2374                }
2375                if let Some(b) = &params.body {
2376                    // A JSON string body is passed through as-is; structured JSON
2377                    // is stringified so the bridge sends valid JSON text.
2378                    rule["body"] = match b {
2379                        serde_json::Value::String(s) => serde_json::json!(s),
2380                        other => serde_json::json!(other.to_string()),
2381                    };
2382                }
2383                if let Some(ct) = &params.content_type {
2384                    rule["content_type"] = serde_json::json!(ct);
2385                }
2386                if let Some(d) = params.delay_ms {
2387                    rule["delay_ms"] = serde_json::json!(d);
2388                }
2389                if let Some(t) = params.times {
2390                    rule["times"] = serde_json::json!(t);
2391                }
2392                let code = format!(
2393                    "return window.__VICTAURI__?.addRoute({})",
2394                    js_string(&rule.to_string())
2395                );
2396                self.eval_bridge(&code, params.webview_label.as_deref())
2397                    .await
2398            }
2399            RouteAction::List => {
2400                self.eval_bridge(
2401                    "return window.__VICTAURI__?.getRouteRules()",
2402                    params.webview_label.as_deref(),
2403                )
2404                .await
2405            }
2406            RouteAction::Clear => {
2407                let Some(id) = params.id else {
2408                    return missing_param("id", "clear");
2409                };
2410                let code = format!("return window.__VICTAURI__?.clearRoute({id})");
2411                self.eval_bridge(&code, params.webview_label.as_deref())
2412                    .await
2413            }
2414            RouteAction::ClearAll => {
2415                self.eval_bridge(
2416                    "return window.__VICTAURI__?.clearRoutes()",
2417                    params.webview_label.as_deref(),
2418                )
2419                .await
2420            }
2421            RouteAction::Matches => {
2422                let limit = params.limit.unwrap_or(100);
2423                let code = format!("return window.__VICTAURI__?.getRouteMatches({limit})");
2424                self.eval_bridge(&code, params.webview_label.as_deref())
2425                    .await
2426            }
2427        }
2428    }
2429
2430    #[tool(
2431        description = "Screencast / visual trace (no CDP). Captures the window at a fixed interval \
2432            into a ring buffer, forming a visual timeline that pairs with `recording` (events) and \
2433            `logs` (network/console). Actions:\n\
2434            - `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set \
2435              `with_events=true` to also start the event recorder.\n\
2436            - `stop`: stop and return a summary (frame count, duration, timestamps).\n\
2437            - `status`: active flag + buffered frame count.\n\
2438            - `frames`: return captured frames as base64 PNGs (`limit` caps how many).",
2439        annotations(
2440            read_only_hint = false,
2441            destructive_hint = false,
2442            idempotent_hint = false,
2443            open_world_hint = false
2444        )
2445    )]
2446    async fn trace(&self, Parameters(params): Parameters<TraceParams>) -> CallToolResult {
2447        if !self.state.privacy.is_tool_enabled("trace")
2448            || !self.state.privacy.is_tool_enabled("screenshot")
2449        {
2450            return tool_disabled("trace");
2451        }
2452        match params.action {
2453            TraceAction::Start => {
2454                let interval = params.interval_ms.unwrap_or(500);
2455                let max_frames = params.max_frames.unwrap_or(60);
2456                let label = params.webview_label.clone();
2457                let generation = self
2458                    .state
2459                    .screencast
2460                    .start(interval, max_frames, label.clone());
2461
2462                let mut events_started = false;
2463                if params.with_events.unwrap_or(false) {
2464                    let session_id = uuid::Uuid::new_v4().to_string();
2465                    if self.state.recorder.start(session_id).is_ok() {
2466                        events_started = true;
2467                    }
2468                }
2469
2470                // Background capture task: snapshot the window each interval until
2471                // the screencast is stopped (or superseded by a newer start).
2472                let bridge = self.bridge.clone();
2473                let screencast = self.state.screencast.clone();
2474                tokio::spawn(async move {
2475                    let t0 = std::time::Instant::now();
2476                    while screencast.is_active() && screencast.generation() == generation {
2477                        if let Ok(handle) = bridge.get_native_handle(label.as_deref())
2478                            && let Ok(png) = crate::screenshot::capture_window(handle).await
2479                        {
2480                            use base64::Engine;
2481                            let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2482                            #[allow(clippy::cast_possible_truncation)]
2483                            screencast.push_frame(t0.elapsed().as_millis() as u64, b64);
2484                        }
2485                        tokio::time::sleep(std::time::Duration::from_millis(
2486                            screencast.interval_ms(),
2487                        ))
2488                        .await;
2489                    }
2490                });
2491
2492                json_result(&serde_json::json!({
2493                    "started": true,
2494                    "interval_ms": interval.max(50),
2495                    "max_frames": max_frames.clamp(1, 600),
2496                    "with_events": events_started,
2497                }))
2498            }
2499            TraceAction::Stop => {
2500                let frame_count = self.state.screencast.stop();
2501                let timestamps = self.state.screencast.frame_timestamps();
2502                let duration_ms = timestamps.last().copied().unwrap_or(0);
2503                let event_count = self.state.recorder.event_count();
2504                json_result(&serde_json::json!({
2505                    "stopped": true,
2506                    "frame_count": frame_count,
2507                    "duration_ms": duration_ms,
2508                    "frame_timestamps_ms": timestamps,
2509                    "recorded_event_count": event_count,
2510                    "hint": "use action=frames to retrieve PNGs; pair with recording/get_events and logs for a full bundle",
2511                }))
2512            }
2513            TraceAction::Status => json_result(&serde_json::json!({
2514                "active": self.state.screencast.is_active(),
2515                "frame_count": self.state.screencast.frame_count(),
2516                "interval_ms": self.state.screencast.interval_ms(),
2517            })),
2518            TraceAction::Frames => {
2519                let limit = params.limit.unwrap_or(0);
2520                let frames = self.state.screencast.frames(limit);
2521                let items: Vec<Content> = frames
2522                    .into_iter()
2523                    .map(|f| Content::image(f.data_b64, "image/png"))
2524                    .collect();
2525                if items.is_empty() {
2526                    return json_result(&serde_json::json!({ "frames": 0 }));
2527                }
2528                CallToolResult::success(items)
2529            }
2530        }
2531    }
2532
2533    #[tool(
2534        description = "Animation introspection (no CDP). Reads the Web Animations API to reveal what \
2535            the webview's animation engine is actually running — duration, delay, easing, iterations, \
2536            keyframes, current progress, and the animating element. Standard DOM, so it works \
2537            identically on WebView2/WKWebView/WebKitGTK. Actions:\n\
2538            - `list`: return all running CSS animations/transitions (optionally scoped by `selector`), \
2539              each with declared `timing`, `computed` progress, `keyframes`, and `target`.\n\
2540            - `scrub`: deterministically pause the target's animation and seek it to `points` \
2541              evenly-spaced steps (default 20), returning the exact geometry curve (rect + transform \
2542              + opacity per step). With `capture=true`, also returns a single contact-sheet filmstrip \
2543              PNG (one image of the whole arc) plus a `manifest` mapping each cell to its progress/time. \
2544              Frozen frames are jank-free, so this beats real-time capture for fast sweeps. CSS-driven \
2545              animations only (JS/rAF animations are not seekable — use `list`/`sample`).\n\
2546            - `sample`: real-time motion recorder. `record=true` arms a requestAnimationFrame watcher \
2547              on `selector` (or the first animating element); then trigger the animation; then call \
2548              with `record=false` to read the measured per-frame curve plus jank stats (dropped frames, \
2549              max frame gap) and declared-vs-measured duration. Works for ANY animation including \
2550              JS/rAF-driven ones. `clear=true` resets recorded sessions.\n\
2551            NOTE: an animation only appears while it is running or pending — trigger it (e.g. show the \
2552            notification) just before calling `list`/`scrub`, or arm `sample` before triggering.",
2553        annotations(
2554            read_only_hint = true,
2555            destructive_hint = false,
2556            idempotent_hint = true,
2557            open_world_hint = false
2558        )
2559    )]
2560    async fn animation(&self, Parameters(params): Parameters<AnimationParams>) -> CallToolResult {
2561        if !self.state.privacy.is_tool_enabled("animation") {
2562            return tool_disabled("animation");
2563        }
2564        match params.action {
2565            AnimationAction::List => {
2566                let sel = params
2567                    .selector
2568                    .as_deref()
2569                    .map_or_else(|| "null".to_string(), js_string);
2570                let code = format!(
2571                    "return window.__VICTAURI__ && window.__VICTAURI__.listAnimations({sel})"
2572                );
2573                match self
2574                    .eval_with_return(&code, params.webview_label.as_deref())
2575                    .await
2576                {
2577                    Ok(result_str) => {
2578                        match serde_json::from_str::<serde_json::Value>(&result_str) {
2579                            Ok(v) => json_result(&v),
2580                            Err(_) => CallToolResult::success(vec![Content::text(result_str)]),
2581                        }
2582                    }
2583                    Err(e) => tool_error(format!("animation list failed: {e}")),
2584                }
2585            }
2586            AnimationAction::Scrub => self.animation_scrub(params).await,
2587            AnimationAction::Sample => {
2588                let label = params.webview_label.as_deref();
2589                let sel = params
2590                    .selector
2591                    .as_deref()
2592                    .map_or_else(|| "null".to_string(), js_string);
2593                let code = if params.record.unwrap_or(false) {
2594                    format!("return window.__VICTAURI__.installSweepRecorder({sel})")
2595                } else {
2596                    let clear = params.clear.unwrap_or(false);
2597                    format!("return window.__VICTAURI__.readSweep({clear})")
2598                };
2599                match self.eval_with_return(&code, label).await {
2600                    Ok(result_str) => {
2601                        match serde_json::from_str::<serde_json::Value>(&result_str) {
2602                            Ok(v) => json_result(&v),
2603                            Err(_) => CallToolResult::success(vec![Content::text(result_str)]),
2604                        }
2605                    }
2606                    Err(e) => tool_error(format!("animation sample failed: {e}")),
2607                }
2608            }
2609        }
2610    }
2611
2612    /// Deterministic pause-seek-capture loop for `animation scrub`. Split out to
2613    /// keep the `#[tool]` method readable.
2614    async fn animation_scrub(&self, params: AnimationParams) -> CallToolResult {
2615        let label = params.webview_label.as_deref();
2616        let sel = params
2617            .selector
2618            .as_deref()
2619            .map_or_else(|| "null".to_string(), js_string);
2620
2621        // 1. Prepare: pause the target's animations, learn the timeline length.
2622        let prep_code = format!("return await window.__VICTAURI__.scrubPrepare({sel})");
2623        let prep_v = match self.eval_with_return(&prep_code, label).await {
2624            Ok(s) => {
2625                serde_json::from_str::<serde_json::Value>(&s).unwrap_or(serde_json::Value::Null)
2626            }
2627            Err(e) => return tool_error(format!("scrub prepare failed: {e}")),
2628        };
2629        if prep_v.get("prepared").and_then(serde_json::Value::as_bool) != Some(true) {
2630            // Surface the helpful error/info object (no target, JS-driven, etc.).
2631            return json_result(&prep_v);
2632        }
2633
2634        let points = params.points.unwrap_or(20).clamp(2, 120);
2635        let capture = params.capture.unwrap_or(false);
2636        let mut curve: Vec<serde_json::Value> = Vec::with_capacity(points);
2637        let mut frames: Vec<crate::filmstrip::Frame> = Vec::new();
2638        let mut manifest: Vec<serde_json::Value> = Vec::new();
2639
2640        // 2. Seek to each evenly-spaced point; capture the frozen frame if asked.
2641        for i in 0..points {
2642            #[allow(clippy::cast_precision_loss)]
2643            let progress = i as f64 / (points - 1) as f64;
2644            let seek_code = format!("return await window.__VICTAURI__.scrubSeek({progress})");
2645            match self.eval_with_return(&seek_code, label).await {
2646                Ok(s) => {
2647                    let v = serde_json::from_str::<serde_json::Value>(&s)
2648                        .unwrap_or(serde_json::Value::Null);
2649                    if capture
2650                        && let Ok(handle) = self.bridge.get_native_handle(label)
2651                        && let Ok((rgba, w, h)) =
2652                            crate::screenshot::capture_window_raw(handle).await
2653                        && let Some(frame) = crate::filmstrip::Frame::new(rgba, w, h)
2654                    {
2655                        manifest.push(serde_json::json!({
2656                            "cell": frames.len(),
2657                            "progress": progress,
2658                            "t": v.get("t").cloned().unwrap_or(serde_json::Value::Null),
2659                        }));
2660                        frames.push(frame);
2661                    }
2662                    curve.push(v);
2663                }
2664                Err(e) => curve.push(serde_json::json!({ "progress": progress, "error": e })),
2665            }
2666        }
2667
2668        // 3. Restore (resume) or leave paused.
2669        let resume = params.restore.unwrap_or(true);
2670        let restore_code = format!("return window.__VICTAURI__.scrubRestore({resume})");
2671        let _ = self.eval_with_return(&restore_code, label).await;
2672
2673        let mut meta = serde_json::json!({
2674            "scrubbed": true,
2675            "points": points,
2676            "duration_ms": prep_v.get("duration").cloned().unwrap_or(serde_json::Value::Null),
2677            "anim_count": prep_v.get("anim_count").cloned().unwrap_or(serde_json::Value::Null),
2678            "target": prep_v.get("target").cloned().unwrap_or(serde_json::Value::Null),
2679            "captured": capture,
2680            "curve": curve,
2681        });
2682
2683        // 4. Compose the filmstrip if we captured frames.
2684        if capture && !frames.is_empty() {
2685            let cols = params
2686                .cols
2687                .unwrap_or_else(|| crate::filmstrip::default_cols(frames.len()));
2688            if let Some((rgba, w, h)) =
2689                crate::filmstrip::compose(&frames, cols, 4, [20, 20, 20, 255])
2690            {
2691                match crate::screenshot::encode_png(w, h, &rgba) {
2692                    Ok(png) => {
2693                        use base64::Engine;
2694                        let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2695                        meta["filmstrip"] = serde_json::json!({
2696                            "cols": cols,
2697                            "frame_count": frames.len(),
2698                            "width": w,
2699                            "height": h,
2700                            "manifest": manifest,
2701                        });
2702                        return CallToolResult::success(vec![
2703                            Content::image(b64, "image/png"),
2704                            Content::text(meta.to_string()),
2705                        ]);
2706                    }
2707                    Err(e) => return tool_error(format!("filmstrip encode failed: {e}")),
2708                }
2709            }
2710        }
2711
2712        json_result(&meta)
2713    }
2714
2715    #[tool(
2716        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).",
2717        annotations(
2718            read_only_hint = true,
2719            destructive_hint = false,
2720            idempotent_hint = true,
2721            open_world_hint = false
2722        )
2723    )]
2724    async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
2725        match params.action {
2726            LogsAction::Console => {
2727                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
2728                let base = if since_arg.is_empty() {
2729                    "window.__VICTAURI__?.getConsoleLogs()".to_string()
2730                } else {
2731                    format!("window.__VICTAURI__?.getConsoleLogs({since_arg})")
2732                };
2733                let code = if let Some(limit) = params.limit {
2734                    format!("return ({base} || []).slice(-{limit})")
2735                } else {
2736                    format!("return {base}")
2737                };
2738                self.eval_bridge(&code, params.webview_label.as_deref())
2739                    .await
2740            }
2741            LogsAction::Network => {
2742                let filter_arg = params
2743                    .filter
2744                    .as_ref()
2745                    .map_or_else(|| "null".to_string(), |f| js_string(f));
2746                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
2747                let source = format!("window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit})");
2748                let code = trimmed_log_js(&source, limit);
2749                self.eval_bridge(&code, params.webview_label.as_deref())
2750                    .await
2751            }
2752            LogsAction::Ipc => {
2753                let wait = params.wait_for_capture.unwrap_or(false);
2754                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
2755                if wait {
2756                    let inner = trimmed_log_js("window.__VICTAURI__.getIpcLog()", limit);
2757                    let code = format!(
2758                        r"return (async function() {{
2759                            await window.__VICTAURI__.waitForIpcComplete(500);
2760                            return (function() {{ {inner} }})();
2761                        }})()"
2762                    );
2763                    let timeout = std::time::Duration::from_millis(5000);
2764                    match self
2765                        .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
2766                        .await
2767                    {
2768                        Ok(result) => CallToolResult::success(vec![Content::text(result)]),
2769                        Err(e) => tool_error(e),
2770                    }
2771                } else {
2772                    let code = trimmed_log_js("window.__VICTAURI__?.getIpcLog()", limit);
2773                    self.eval_bridge(&code, params.webview_label.as_deref())
2774                        .await
2775                }
2776            }
2777            LogsAction::Navigation => {
2778                let code = if let Some(limit) = params.limit {
2779                    format!(
2780                        "return (window.__VICTAURI__?.getNavigationLog() || []).slice(-{limit})"
2781                    )
2782                } else {
2783                    "return window.__VICTAURI__?.getNavigationLog()".to_string()
2784                };
2785                self.eval_bridge(&code, params.webview_label.as_deref())
2786                    .await
2787            }
2788            LogsAction::Dialogs => {
2789                let code = if let Some(limit) = params.limit {
2790                    format!("return (window.__VICTAURI__?.getDialogLog() || []).slice(-{limit})")
2791                } else {
2792                    "return window.__VICTAURI__?.getDialogLog()".to_string()
2793                };
2794                self.eval_bridge(&code, params.webview_label.as_deref())
2795                    .await
2796            }
2797            LogsAction::Events => {
2798                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
2799                let base = if since_arg.is_empty() {
2800                    "window.__VICTAURI__?.getEventStream()".to_string()
2801                } else {
2802                    format!("window.__VICTAURI__?.getEventStream({since_arg})")
2803                };
2804                let code = if let Some(limit) = params.limit {
2805                    format!("return ({base} || []).slice(-{limit})")
2806                } else {
2807                    format!("return {base}")
2808                };
2809                self.eval_bridge(&code, params.webview_label.as_deref())
2810                    .await
2811            }
2812            LogsAction::SlowIpc => {
2813                let Some(threshold) = params.threshold_ms else {
2814                    return missing_param("threshold_ms", "slow_ipc");
2815                };
2816                let limit = params.limit.unwrap_or(20);
2817                let mb = MAX_LOG_FIELD_BYTES;
2818                let code = format!(
2819                    r"return (function() {{
2820                        var MB = {mb};
2821                        function trimField(v) {{
2822                            if (typeof v === 'string') return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
2823                            if (v && typeof v === 'object') {{ var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }} if (s.length > MB) return '[truncated ' + s.length + ' bytes]'; }}
2824                            return v;
2825                        }}
2826                        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; }}
2827                        var log = window.__VICTAURI__?.getIpcLog() || [];
2828                        var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold}; }});
2829                        slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
2830                        return {{ threshold_ms: {threshold}, count: Math.min(slow.length, {limit}), calls: slow.slice(0, {limit}).map(trimEntry) }};
2831                    }})()",
2832                );
2833                self.eval_bridge(&code, None).await
2834            }
2835            LogsAction::Clear => {
2836                // Clearing the IPC/network logs erases captured evidence — a
2837                // mutation of observable state — so it is gated separately and
2838                // excluded from the read-only Observe profile (red-team P1).
2839                if !self.state.privacy.is_tool_enabled("logs.clear") {
2840                    return tool_disabled("logs.clear");
2841                }
2842                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'] }; })()";
2843                self.eval_bridge(code, params.webview_label.as_deref())
2844                    .await
2845            }
2846        }
2847    }
2848
2849    // ── Backend Introspection ────────────────────────────────────────────────
2850
2851    #[tool(
2852        description = "Deep backend introspection — command profiling, IPC contract testing, \
2853            coverage, startup timing, capability auditing, database diagnostics, process \
2854            enumeration, and event bus monitoring. \
2855            These features exploit Victauri's position inside the Rust process.\n\n\
2856            Actions:\n\
2857            - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n\
2858            - `coverage`: Which registered commands have been called during this session.\n\
2859            - `command_catalog`: Per-command argument + result SHAPES mined from the live IPC log, merged with the registry — real call/return schemas even for apps that don't use #[inspectable] (where get_registry is names-only). The highest-signal way to learn how to drive an app's commands.\n\
2860            - `contract_record`: Record a command's response shape as a baseline (requires `command`).\n\
2861            - `contract_check`: Check all recorded contracts for schema drift.\n\
2862            - `contract_list`: List all recorded contract baselines.\n\
2863            - `contract_clear`: Clear all recorded contract baselines.\n\
2864            - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
2865            - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
2866            - `db_health`: Read-only SQLite database diagnostics (journal mode, WAL presence, page stats).\n\
2867            - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
2868            - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
2869            - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
2870            - `event_bus`: List captured Tauri events + app events (auto-intercepted via listen_any — no app opt-in needed). Returns the newest events per category (default 100) so the full buffers (up to ~11k events / megabytes) never overflow the result; `count` is the true total and `truncated` flags a capped slice. Scope via the `args` object: `{\"action\":\"event_bus\",\"args\":{\"limit\":500,\"since_ms\":5000}}`.\n\
2871            - `event_bus_clear`: Clear the event bus capture buffer.",
2872        annotations(
2873            read_only_hint = true,
2874            destructive_hint = false,
2875            idempotent_hint = true,
2876            open_world_hint = false
2877        )
2878    )]
2879    async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
2880        if !self.state.privacy.is_tool_enabled("introspect") {
2881            return tool_disabled("introspect");
2882        }
2883
2884        match params.action {
2885            IntrospectAction::CommandTimings => {
2886                let mut stats = self.state.command_timings.all_stats();
2887                let driven_count = stats.len();
2888                if let Some(threshold) = params.slow_threshold_ms {
2889                    stats.retain(|s| s.avg_ms >= threshold);
2890                }
2891
2892                // Real frontend traffic: derive per-command latency from the live IPC
2893                // log so the profiler is not blind to commands the app itself drives.
2894                // `command_timings` (above) only records Victauri-driven invoke_command
2895                // calls — on a running app that counter is typically 0 while the app
2896                // makes hundreds of real calls. The IPC log captures those with
2897                // duration; the name+duration projection stays under the eval cap.
2898                let code = ipc_timing_projection_js(None);
2899                let mut ipc_traffic = match self
2900                    .eval_with_return(&code, params.webview_label.as_deref())
2901                    .await
2902                {
2903                    Ok(json_str) => serde_json::from_str::<Vec<serde_json::Value>>(&json_str)
2904                        .map(|entries| ipc_timing_stats(&entries))
2905                        .unwrap_or_default(),
2906                    Err(_) => Vec::new(),
2907                };
2908                if let Some(threshold) = params.slow_threshold_ms {
2909                    ipc_traffic.retain(|s| {
2910                        s.get("avg_ms")
2911                            .and_then(serde_json::Value::as_f64)
2912                            .is_some_and(|a| a >= threshold)
2913                    });
2914                }
2915
2916                let result = serde_json::json!({
2917                    "commands": stats,
2918                    "total_commands_profiled": driven_count,
2919                    "ipc_traffic": ipc_traffic,
2920                    "ipc_commands_observed": ipc_traffic.len(),
2921                    "slow_threshold_ms": params.slow_threshold_ms,
2922                    "note": "`commands` profiles ONLY commands you drove through Victauri's \
2923                             invoke_command tool (often empty on a live app). `ipc_traffic` \
2924                             profiles the app's REAL frontend IPC, derived from the live IPC \
2925                             log (per-command call_count + min/max/avg/p95 latency) — that is \
2926                             the one reflecting actual usage.",
2927                });
2928                json_result(&result)
2929            }
2930            IntrospectAction::Coverage => {
2931                let registered: Vec<String> = self
2932                    .state
2933                    .registry
2934                    .list()
2935                    .iter()
2936                    .map(|c| c.name.clone())
2937                    .collect();
2938
2939                // Project to command NAMES ONLY. The previous full `getIpcLog()` carried
2940                // request/response bodies and blew the eval result cap on busy apps,
2941                // silently returning an empty set and reporting "0 invoked" despite live
2942                // traffic. This is the same name projection ghost detection uses.
2943                let code = ghost_ipc_projection_js(None);
2944                let (invoked, ipc_calls_observed): (std::collections::HashSet<String>, usize) =
2945                    match self
2946                        .eval_with_return(&code, params.webview_label.as_deref())
2947                        .await
2948                    {
2949                        Ok(json_str) => match serde_json::from_str::<Vec<String>>(&json_str) {
2950                            Ok(names) => {
2951                                let count = names.len();
2952                                (names.into_iter().collect(), count)
2953                            }
2954                            Err(_) => (std::collections::HashSet::new(), 0),
2955                        },
2956                        Err(_) => (std::collections::HashSet::new(), 0),
2957                    };
2958
2959                let uncovered: Vec<&String> = registered
2960                    .iter()
2961                    .filter(|cmd| !invoked.contains(cmd.as_str()))
2962                    .collect();
2963
2964                let coverage_pct = if registered.is_empty() {
2965                    100.0
2966                } else {
2967                    let covered = registered.len() - uncovered.len();
2968                    (covered as f64 / registered.len() as f64) * 100.0
2969                };
2970
2971                let note = if registered.is_empty() {
2972                    Some(
2973                        "The introspection registry is empty (the app does not use \
2974                         #[inspectable]/register_command_names), so coverage_pct is a \
2975                         placeholder 100%. `invoked_not_registered` still lists the real \
2976                         commands seen on the live IPC log — use it to inventory actual \
2977                         traffic.",
2978                    )
2979                } else if ipc_calls_observed == 0 {
2980                    Some(
2981                        "No IPC calls were observed on the live log. If the app is actively \
2982                         making calls, confirm the target webview and that Tauri IPC routes \
2983                         through fetch to ipc.localhost (some commands use the native channel).",
2984                    )
2985                } else {
2986                    None
2987                };
2988
2989                let result = serde_json::json!({
2990                    "registered_commands": registered.len(),
2991                    "invoked_commands": invoked.len(),
2992                    "ipc_calls_observed": ipc_calls_observed,
2993                    "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
2994                    "uncovered": uncovered,
2995                    "invoked_not_registered": invoked.iter()
2996                        .filter(|cmd| !registered.contains(cmd))
2997                        .collect::<Vec<_>>(),
2998                    "note": note,
2999                });
3000                json_result(&result)
3001            }
3002            IntrospectAction::CommandCatalog => {
3003                // Mine the live IPC log for per-command argument + result SHAPES (inferred
3004                // in JS, bodies never shipped — so it stays under the eval cap on busy apps)
3005                // and merge with the #[inspectable] registry. This is the answer to a real
3006                // live gap: an app without #[inspectable] (e.g. 4DA — 379 commands, every
3007                // registry field null) gives an agent command NAMES but no call/return
3008                // schema; the IPC log holds the actual shapes, so we project them out.
3009                let code = ipc_catalog_projection_js();
3010                let ipc_entries: Vec<serde_json::Value> = match self
3011                    .eval_with_return(&code, params.webview_label.as_deref())
3012                    .await
3013                {
3014                    Ok(json_str) => serde_json::from_str(&json_str).unwrap_or_default(),
3015                    Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
3016                };
3017
3018                let registry = self.state.registry.list();
3019                let catalog = merge_command_catalog(&ipc_entries, &registry);
3020                let observed = catalog
3021                    .iter()
3022                    .filter(|c| {
3023                        c.get("observed")
3024                            .and_then(serde_json::Value::as_bool)
3025                            .unwrap_or(false)
3026                    })
3027                    .count();
3028
3029                let result = serde_json::json!({
3030                    "catalog": catalog,
3031                    "observed_count": observed,
3032                    "registered_count": registry.len(),
3033                    "total": catalog.len(),
3034                    "note": "`arg_shape`/`result_shape` are STRUCTURES inferred from the live \
3035                             IPC log (keys + value types, not values) — how to CALL each command \
3036                             and what it RETURNS, even for apps that don't use #[inspectable]. \
3037                             `observed:false` means the command is in the registry but hasn't \
3038                             been seen on the wire this session (drive the app's UI to populate \
3039                             its shape). `declared_*` fields, when present, come from \
3040                             #[inspectable] and are authoritative over the inferred shape.",
3041                });
3042                json_result(&result)
3043            }
3044            IntrospectAction::ContractRecord => {
3045                let Some(command) = params.command else {
3046                    return missing_param("command", "contract_record");
3047                };
3048                // contract_record invokes the command with caller-supplied args, so
3049                // it must honour the same allow/blocklist as invoke_command (audit #30).
3050                if !self.state.privacy.is_invoke_allowed(&command)
3051                    || !self.state.privacy.is_command_allowed(&command)
3052                {
3053                    return tool_error(format!(
3054                        "command '{command}' is blocked by privacy configuration"
3055                    ));
3056                }
3057                let args_json = params.args.unwrap_or(serde_json::json!({}));
3058                let args_str =
3059                    serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
3060                let code = format!(
3061                    "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
3062                    js_string(&command)
3063                );
3064                match self
3065                    .eval_with_return(&code, params.webview_label.as_deref())
3066                    .await
3067                {
3068                    Ok(result_str) => {
3069                        let value: serde_json::Value = serde_json::from_str(&result_str)
3070                            .unwrap_or(serde_json::Value::String(result_str.clone()));
3071                        let shape = crate::introspection::JsonShape::from_value(&value);
3072                        let sample = if result_str.len() > 4096 {
3073                            format!("{}...(truncated)", &result_str[..4096])
3074                        } else {
3075                            result_str
3076                        };
3077                        let baseline = crate::introspection::ContractBaseline {
3078                            command: command.clone(),
3079                            args: args_json,
3080                            shape: shape.clone(),
3081                            sample,
3082                            recorded_at: chrono_now(),
3083                        };
3084                        self.state.contract_store.record(baseline);
3085                        let result = serde_json::json!({
3086                            "recorded": true,
3087                            "command": command,
3088                            "shape_type": shape.type_name(),
3089                        });
3090                        json_result(&result)
3091                    }
3092                    Err(e) => tool_error(format!(
3093                        "failed to invoke '{command}' for contract recording: {e}"
3094                    )),
3095                }
3096            }
3097            IntrospectAction::ContractCheck => {
3098                let baselines = self.state.contract_store.all();
3099                if baselines.is_empty() {
3100                    return json_result(&serde_json::json!({
3101                        "checked": 0,
3102                        "message": "no contract baselines recorded — use contract_record first",
3103                    }));
3104                }
3105                let mut results = Vec::new();
3106                for baseline in &baselines {
3107                    // Re-checking a baseline re-invokes the command; honour the
3108                    // allow/blocklist in case it changed since recording (audit #30).
3109                    if !self.state.privacy.is_invoke_allowed(&baseline.command)
3110                        || !self.state.privacy.is_command_allowed(&baseline.command)
3111                    {
3112                        continue;
3113                    }
3114                    let args_str =
3115                        serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
3116                    let code = format!(
3117                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
3118                        js_string(&baseline.command)
3119                    );
3120                    match self
3121                        .eval_with_return(&code, params.webview_label.as_deref())
3122                        .await
3123                    {
3124                        Ok(result_str) => {
3125                            let value: serde_json::Value = serde_json::from_str(&result_str)
3126                                .unwrap_or(serde_json::Value::String(result_str));
3127                            let current_shape = crate::introspection::JsonShape::from_value(&value);
3128                            let drift = crate::introspection::diff_shapes(
3129                                &baseline.shape,
3130                                &current_shape,
3131                                &baseline.command,
3132                            );
3133                            results.push(drift);
3134                        }
3135                        Err(e) => {
3136                            results.push(crate::introspection::ContractDrift {
3137                                command: baseline.command.clone(),
3138                                new_fields: Vec::new(),
3139                                removed_fields: Vec::new(),
3140                                type_changes: Vec::new(),
3141                                shape_matches: false,
3142                            });
3143                            tracing::warn!(
3144                                command = %baseline.command,
3145                                error = %e,
3146                                "contract check invocation failed"
3147                            );
3148                        }
3149                    }
3150                }
3151                let passing = results.iter().filter(|r| r.shape_matches).count();
3152                let result = serde_json::json!({
3153                    "checked": results.len(),
3154                    "passing": passing,
3155                    "failing": results.len() - passing,
3156                    "contracts": results,
3157                });
3158                json_result(&result)
3159            }
3160            IntrospectAction::ContractList => {
3161                let baselines = self.state.contract_store.all();
3162                let result = serde_json::json!({
3163                    "count": baselines.len(),
3164                    "baselines": baselines.iter().map(|b| serde_json::json!({
3165                        "command": b.command,
3166                        "shape_type": b.shape.type_name(),
3167                        "recorded_at": b.recorded_at,
3168                    })).collect::<Vec<_>>(),
3169                });
3170                json_result(&result)
3171            }
3172            IntrospectAction::ContractClear => {
3173                let cleared = self.state.contract_store.clear();
3174                json_result(&serde_json::json!({
3175                    "cleared": cleared,
3176                }))
3177            }
3178            IntrospectAction::StartupTiming => {
3179                let phases = self.state.startup_timeline.report();
3180                let result = serde_json::json!({
3181                    "phases": phases,
3182                    "total_ms": self.state.startup_timeline.total_ms(),
3183                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
3184                });
3185                json_result(&result)
3186            }
3187            IntrospectAction::Capabilities => {
3188                let config = self.bridge.tauri_config();
3189                let live_windows = self.bridge.list_window_labels();
3190
3191                let result = serde_json::json!({
3192                    "app": {
3193                        "identifier": config.get("identifier"),
3194                        "product_name": config.get("product_name"),
3195                        "version": config.get("version"),
3196                    },
3197                    "security": config.get("security"),
3198                    "configured_windows": config.get("windows"),
3199                    "live_windows": live_windows,
3200                    "configured_plugins": config.get("plugins"),
3201                    "victauri": {
3202                        "registered_commands": self.state.registry.list().len(),
3203                        "redaction_enabled": self.state.privacy.redaction_enabled,
3204                        "privacy_profile": format!("{:?}", self.state.privacy.profile),
3205                        "disabled_tools": &self.state.privacy.disabled_tools,
3206                    },
3207                });
3208                json_result(&result)
3209            }
3210            #[allow(unused_variables)]
3211            IntrospectAction::DbHealth => {
3212                #[cfg(feature = "sqlite")]
3213                {
3214                    let db_path = params.db_path.clone();
3215                    match self.run_db_health(db_path.as_deref()).await {
3216                        Ok(health) => json_result(&health),
3217                        Err(e) => tool_error(format!("db_health failed: {e}")),
3218                    }
3219                }
3220                #[cfg(not(feature = "sqlite"))]
3221                {
3222                    tool_error("SQLite support not compiled in — enable the `sqlite` feature")
3223                }
3224            }
3225            IntrospectAction::PluginState => {
3226                let recording_active = self.state.recorder.is_recording();
3227                let recording_events = self.state.recorder.event_count();
3228                let result = serde_json::json!({
3229                    "event_log": {
3230                        "size": self.state.event_log.len(),
3231                        "capacity": self.state.event_log.capacity(),
3232                    },
3233                    "registry": {
3234                        "commands_registered": self.state.registry.list().len(),
3235                    },
3236                    "recording": {
3237                        "active": recording_active,
3238                        "events_captured": recording_events,
3239                    },
3240                    "faults": {
3241                        "active_rules": self.state.fault_registry.list().len(),
3242                    },
3243                    "contracts": {
3244                        "baselines_recorded": self.state.contract_store.all().len(),
3245                    },
3246                    "timings": {
3247                        "commands_profiled": self.state.command_timings.all_stats().len(),
3248                    },
3249                    "event_bus": {
3250                        "captured_events": self.state.event_bus.len(),
3251                    },
3252                    "tasks": {
3253                        "total": self.state.task_tracker.list().len(),
3254                        "active": self.state.task_tracker.active_count(),
3255                    },
3256                    "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
3257                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
3258                    "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
3259                });
3260                json_result(&result)
3261            }
3262            IntrospectAction::Processes => {
3263                let pid = std::process::id();
3264                let uptime = self.state.started_at.elapsed();
3265                let children = crate::introspection::enumerate_child_processes();
3266                let host_memory = crate::memory::current_stats();
3267
3268                let result = serde_json::json!({
3269                    "host": {
3270                        "pid": pid,
3271                        "uptime_secs": uptime.as_secs(),
3272                        "platform": std::env::consts::OS,
3273                        "arch": std::env::consts::ARCH,
3274                        "memory": host_memory,
3275                    },
3276                    "children": children.iter().map(|c| serde_json::json!({
3277                        "pid": c.pid,
3278                        "name": c.name,
3279                        "memory_bytes": c.memory_bytes,
3280                    })).collect::<Vec<_>>(),
3281                    "child_count": children.len(),
3282                    "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
3283                });
3284                json_result(&result)
3285            }
3286            IntrospectAction::PluginTasks => {
3287                let tasks = self.state.task_tracker.list();
3288                let active = self.state.task_tracker.active_count();
3289                let result = serde_json::json!({
3290                    "total": tasks.len(),
3291                    "active": active,
3292                    "finished": tasks.len() - active,
3293                    "tasks": tasks,
3294                });
3295                json_result(&result)
3296            }
3297            IntrospectAction::EventBus => {
3298                // Default cap so the full buffers (up to 1k Tauri + 10k app events, often
3299                // megabytes / tens of thousands of lines) can never overflow the tool result
3300                // cap (VIC-4). Newest events first; `count` is the full total so a truncated
3301                // slice is always diagnosable. Optional `limit` / `since_ms` are read from the
3302                // generic `args` object (a dedicated public field would be a semver-major break).
3303                let opts = params.args.as_ref();
3304                let limit = opts
3305                    .and_then(|a| a.get("limit"))
3306                    .and_then(serde_json::Value::as_u64)
3307                    .and_then(|n| usize::try_from(n).ok())
3308                    .unwrap_or(100);
3309                let since_ms = opts
3310                    .and_then(|a| a.get("since_ms"))
3311                    .and_then(serde_json::Value::as_u64);
3312                let cutoff = since_ms.map(|ms| {
3313                    chrono::Utc::now()
3314                        - chrono::TimeDelta::milliseconds(i64::try_from(ms).unwrap_or(i64::MAX))
3315                });
3316
3317                let all_tauri = self.state.event_bus.events();
3318                let tauri_total = all_tauri.len();
3319                let tauri_matched: Vec<_> = all_tauri
3320                    .into_iter()
3321                    .filter(|e| match cutoff {
3322                        Some(cut) => chrono::DateTime::parse_from_rfc3339(&e.timestamp)
3323                            .map_or(true, |t| t.with_timezone(&chrono::Utc) >= cut),
3324                        None => true,
3325                    })
3326                    .collect();
3327                let tauri_matched_count = tauri_matched.len();
3328                let tauri_events: Vec<_> = tauri_matched.into_iter().rev().take(limit).collect();
3329
3330                // Exclude Victauri's own infrastructure events (plugin:victauri|* IPC etc.) —
3331                // noise in a diagnostic timeline; the `explain` tools already filter them via
3332                // `is_internal()`.
3333                let all_app: Vec<_> = self
3334                    .state
3335                    .event_log
3336                    .snapshot()
3337                    .into_iter()
3338                    .filter(|e| !e.is_internal())
3339                    .collect();
3340                let app_total = all_app.len();
3341                let app_matched: Vec<_> = match cutoff {
3342                    Some(cut) => all_app
3343                        .into_iter()
3344                        .filter(|e| e.timestamp() >= cut)
3345                        .collect(),
3346                    None => all_app,
3347                };
3348                let app_matched_count = app_matched.len();
3349                let app_events: Vec<_> = app_matched.into_iter().rev().take(limit).collect();
3350
3351                let result = serde_json::json!({
3352                    "limit": limit,
3353                    "since_ms": since_ms,
3354                    "tauri_events": {
3355                        "count": tauri_total,
3356                        "matched": tauri_matched_count,
3357                        "returned": tauri_events.len(),
3358                        "truncated": tauri_matched_count > tauri_events.len(),
3359                        "events": tauri_events,
3360                    },
3361                    "app_events": {
3362                        "count": app_total,
3363                        "matched": app_matched_count,
3364                        "returned": app_events.len(),
3365                        "truncated": app_matched_count > app_events.len(),
3366                        "capacity": self.state.event_log.capacity(),
3367                        "events": app_events,
3368                    },
3369                });
3370                json_result(&result)
3371            }
3372            IntrospectAction::EventBusClear => {
3373                let tauri_cleared = self.state.event_bus.clear();
3374                self.state.event_log.clear();
3375                json_result(&serde_json::json!({
3376                    "tauri_events_cleared": tauri_cleared,
3377                    "app_events_cleared": true,
3378                }))
3379            }
3380        }
3381    }
3382
3383    // ── Fault Injection / Chaos Engineering ──────────────────────────────────
3384
3385    #[tool(
3386        description = "Probe a backend command handler under failure by faulting it for chaos engineering. \
3387            Simulate slow commands, backend errors, dropped responses, and corrupted data. \
3388            SCOPE: faults apply ONLY to commands you run via this server's `invoke_command` tool — \
3389            they do NOT intercept the app's real user-driven IPC (window.__TAURI_INTERNALS__.invoke), \
3390            which runs below the layer Victauri can reach. Use this to test a handler's error path when \
3391            YOU drive it; it does not reproduce a failure a user clicking the UI would see.\n\n\
3392            Actions:\n\
3393            - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`.\n\
3394            - `list`: List all active fault injection rules.\n\
3395            - `clear`: Remove a specific fault rule (requires `command`).\n\
3396            - `clear_all`: Remove all fault rules.",
3397        annotations(
3398            read_only_hint = false,
3399            destructive_hint = true,
3400            idempotent_hint = false,
3401            open_world_hint = false
3402        )
3403    )]
3404    async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
3405        if !self.state.privacy.is_tool_enabled("fault") {
3406            return tool_disabled("fault");
3407        }
3408
3409        match params.action {
3410            FaultAction::Inject => {
3411                let Some(command) = params.command else {
3412                    return missing_param("command", "inject");
3413                };
3414                let Some(fault_kind) = params.fault_type else {
3415                    return missing_param("fault_type", "inject");
3416                };
3417                let fault_type = match fault_kind {
3418                    FaultKind::Delay => {
3419                        let delay_ms = params.delay_ms.unwrap_or(1000);
3420                        crate::introspection::FaultType::Delay { delay_ms }
3421                    }
3422                    FaultKind::Error => {
3423                        let message = params
3424                            .error_message
3425                            .unwrap_or_else(|| "injected fault".to_string());
3426                        crate::introspection::FaultType::Error { message }
3427                    }
3428                    FaultKind::Drop => crate::introspection::FaultType::Drop,
3429                    FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
3430                };
3431                let config = crate::introspection::FaultConfig {
3432                    command: command.clone(),
3433                    fault_type: fault_type.clone(),
3434                    trigger_count: 0,
3435                    max_triggers: params.max_triggers.unwrap_or(0),
3436                    created_at: std::time::Instant::now(),
3437                };
3438                self.state.fault_registry.inject(config);
3439                let result = serde_json::json!({
3440                    "injected": true,
3441                    "command": command,
3442                    "fault_type": fault_type,
3443                    "max_triggers": params.max_triggers.unwrap_or(0),
3444                });
3445                json_result(&result)
3446            }
3447            FaultAction::List => {
3448                let faults = self.state.fault_registry.list();
3449                let result = serde_json::json!({
3450                    "count": faults.len(),
3451                    "faults": faults.iter().map(|f| serde_json::json!({
3452                        "command": f.command,
3453                        "fault_type": f.fault_type,
3454                        "trigger_count": f.trigger_count,
3455                        "max_triggers": f.max_triggers,
3456                    })).collect::<Vec<_>>(),
3457                });
3458                json_result(&result)
3459            }
3460            FaultAction::Clear => {
3461                let Some(command) = params.command else {
3462                    return missing_param("command", "clear");
3463                };
3464                let removed = self.state.fault_registry.clear(&command);
3465                json_result(&serde_json::json!({
3466                    "removed": removed,
3467                    "command": command,
3468                }))
3469            }
3470            FaultAction::ClearAll => {
3471                let removed = self.state.fault_registry.clear_all();
3472                json_result(&serde_json::json!({
3473                    "removed": removed,
3474                }))
3475            }
3476        }
3477    }
3478
3479    // ── Cross-Layer Explanation ────────────────────────────────────────────
3480
3481    #[tool(
3482        description = "Correlate recent activity across all layers into a coherent narrative. \
3483            CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
3484            + window events across the Rust backend and webview simultaneously.\n\n\
3485            Actions:\n\
3486            - `summary`: High-level activity summary for the last N seconds (default 30). \
3487              Counts IPC calls, DOM mutations, console entries, network requests, errors.\n\
3488            - `last_action`: Correlate the most recent burst of events into a causal timeline \
3489              (e.g. 'IPC call → DOM update → console.log').\n\
3490            - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
3491        annotations(
3492            read_only_hint = true,
3493            destructive_hint = false,
3494            idempotent_hint = true,
3495            open_world_hint = false
3496        )
3497    )]
3498    async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
3499        if !self.state.privacy.is_tool_enabled("explain") {
3500            return tool_disabled("explain");
3501        }
3502
3503        match params.action {
3504            ExplainAction::Summary => {
3505                let secs = params.seconds.unwrap_or(30);
3506                let since = chrono::Utc::now()
3507                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3508                let events = self.state.event_log.since(since);
3509
3510                let mut ipc_count = 0u64;
3511                let mut dom_mutations = 0u64;
3512                let mut state_changes = 0u64;
3513                let mut console_count = 0u64;
3514                let mut window_events = 0u64;
3515                let mut interactions = 0u64;
3516                let mut top_commands: HashMap<String, u64> = HashMap::new();
3517                let mut errors: Vec<String> = Vec::new();
3518
3519                for event in &events {
3520                    match event {
3521                        victauri_core::AppEvent::Ipc(call) => {
3522                            ipc_count += 1;
3523                            *top_commands.entry(call.command.clone()).or_insert(0) += 1;
3524                            if let victauri_core::IpcResult::Err(e) = &call.result {
3525                                errors.push(format!("IPC {}: {e}", call.command));
3526                            }
3527                        }
3528                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
3529                            dom_mutations += u64::from(*mutation_count)
3530                        }
3531                        victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
3532                        victauri_core::AppEvent::Console { level, message, .. } => {
3533                            console_count += 1;
3534                            if level == "error" {
3535                                errors.push(format!("console.error: {message}"));
3536                            }
3537                        }
3538                        victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
3539                        victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
3540                        _ => {}
3541                    }
3542                }
3543
3544                let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
3545                sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
3546                let top: Vec<_> = sorted_cmds.iter().take(5).collect();
3547
3548                let narrative = format!(
3549                    "{ipc_count} IPC call{} in the last {secs}s{}. \
3550                     {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
3551                     {console_count} console message{}, {window_events} window event{}. {}.",
3552                    if ipc_count == 1 { "" } else { "s" },
3553                    if top.is_empty() {
3554                        String::new()
3555                    } else {
3556                        format!(
3557                            ", dominated by {}",
3558                            top.iter()
3559                                .map(|(cmd, n)| format!("{cmd} ({n}x)"))
3560                                .collect::<Vec<_>>()
3561                                .join(", ")
3562                        )
3563                    },
3564                    if dom_mutations == 1 { "" } else { "s" },
3565                    if interactions == 1 { "" } else { "s" },
3566                    if console_count == 1 { "" } else { "s" },
3567                    if window_events == 1 { "" } else { "s" },
3568                    if errors.is_empty() {
3569                        "No errors".to_string()
3570                    } else {
3571                        format!(
3572                            "{} error{}",
3573                            errors.len(),
3574                            if errors.len() == 1 { "" } else { "s" }
3575                        )
3576                    },
3577                );
3578
3579                let result = serde_json::json!({
3580                    "time_window_secs": secs,
3581                    "total_events": events.len(),
3582                    "ipc_calls": ipc_count,
3583                    "dom_mutations": dom_mutations,
3584                    "state_changes": state_changes,
3585                    "console_messages": console_count,
3586                    "window_events": window_events,
3587                    "interactions": interactions,
3588                    "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
3589                        serde_json::json!({"command": cmd, "count": n})
3590                    }).collect::<Vec<_>>(),
3591                    "errors": errors,
3592                    "narrative": narrative,
3593                });
3594                json_result(&result)
3595            }
3596            ExplainAction::LastAction => {
3597                let secs = params.seconds.unwrap_or(5);
3598                let since = chrono::Utc::now()
3599                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3600                let events = self.state.event_log.since(since);
3601
3602                let timeline: Vec<serde_json::Value> = events
3603                    .iter()
3604                    .filter(|e| !e.is_internal())
3605                    .map(|event| match event {
3606                        victauri_core::AppEvent::Ipc(call) => serde_json::json!({
3607                            "time": call.timestamp.to_rfc3339_opts(
3608                                chrono::SecondsFormat::Millis, true
3609                            ),
3610                            "type": "ipc",
3611                            "detail": format!(
3612                                "{} {} ({}ms)",
3613                                call.command,
3614                                call.result,
3615                                call.duration_ms.unwrap_or(0)
3616                            ),
3617                        }),
3618                        victauri_core::AppEvent::DomMutation {
3619                            timestamp,
3620                            mutation_count,
3621                            webview_label,
3622                        } => serde_json::json!({
3623                            "time": timestamp.to_rfc3339_opts(
3624                                chrono::SecondsFormat::Millis, true
3625                            ),
3626                            "type": "dom_mutation",
3627                            "detail": format!(
3628                                "{mutation_count} element{} updated in {webview_label}",
3629                                if *mutation_count == 1 { "" } else { "s" }
3630                            ),
3631                        }),
3632                        victauri_core::AppEvent::DomInteraction {
3633                            timestamp,
3634                            action,
3635                            selector,
3636                            ..
3637                        } => serde_json::json!({
3638                            "time": timestamp.to_rfc3339_opts(
3639                                chrono::SecondsFormat::Millis, true
3640                            ),
3641                            "type": "interaction",
3642                            "detail": format!("{action} on {selector}"),
3643                        }),
3644                        victauri_core::AppEvent::StateChange {
3645                            timestamp,
3646                            key,
3647                            caused_by,
3648                        } => serde_json::json!({
3649                            "time": timestamp.to_rfc3339_opts(
3650                                chrono::SecondsFormat::Millis, true
3651                            ),
3652                            "type": "state_change",
3653                            "detail": format!(
3654                                "{key} changed{}",
3655                                caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
3656                            ),
3657                        }),
3658                        victauri_core::AppEvent::Console {
3659                            timestamp,
3660                            level,
3661                            message,
3662                        } => serde_json::json!({
3663                            "time": timestamp.to_rfc3339_opts(
3664                                chrono::SecondsFormat::Millis, true
3665                            ),
3666                            "type": "console",
3667                            "detail": format!("console.{level}: {message}"),
3668                        }),
3669                        victauri_core::AppEvent::WindowEvent {
3670                            timestamp,
3671                            label,
3672                            event,
3673                        } => serde_json::json!({
3674                            "time": timestamp.to_rfc3339_opts(
3675                                chrono::SecondsFormat::Millis, true
3676                            ),
3677                            "type": "window_event",
3678                            "detail": format!("{event} on window '{label}'"),
3679                        }),
3680                        _ => serde_json::json!({
3681                            "time": event.timestamp().to_rfc3339_opts(
3682                                chrono::SecondsFormat::Millis, true
3683                            ),
3684                            "type": "other",
3685                            "detail": "unknown event type",
3686                        }),
3687                    })
3688                    .collect();
3689
3690                let narrative = if timeline.is_empty() {
3691                    format!("No activity in the last {secs}s.")
3692                } else {
3693                    let parts: Vec<String> = timeline
3694                        .iter()
3695                        .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
3696                        .map(String::from)
3697                        .collect();
3698                    parts.join(" → ")
3699                };
3700
3701                let result = serde_json::json!({
3702                    "time_window_secs": secs,
3703                    "event_count": timeline.len(),
3704                    "timeline": timeline,
3705                    "narrative": narrative,
3706                });
3707                json_result(&result)
3708            }
3709            ExplainAction::Diff => {
3710                let secs = params.seconds.unwrap_or(10);
3711                let since = chrono::Utc::now()
3712                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3713                let events = self.state.event_log.since(since);
3714
3715                let mut ipc_commands: Vec<String> = Vec::new();
3716                let mut dom_changes = 0u64;
3717                let mut error_count = 0u64;
3718                let mut interaction_count = 0u64;
3719                let mut console_messages = 0u64;
3720
3721                for event in &events {
3722                    if event.is_internal() {
3723                        continue;
3724                    }
3725                    match event {
3726                        victauri_core::AppEvent::Ipc(call) => {
3727                            ipc_commands.push(call.command.clone());
3728                            if matches!(call.result, victauri_core::IpcResult::Err(_)) {
3729                                error_count += 1;
3730                            }
3731                        }
3732                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
3733                            dom_changes += u64::from(*mutation_count)
3734                        }
3735                        victauri_core::AppEvent::DomInteraction { .. } => {
3736                            interaction_count += 1;
3737                        }
3738                        victauri_core::AppEvent::Console { level, .. } => {
3739                            console_messages += 1;
3740                            if level == "error" {
3741                                error_count += 1;
3742                            }
3743                        }
3744                        _ => {}
3745                    }
3746                }
3747
3748                ipc_commands.dedup();
3749
3750                let result = serde_json::json!({
3751                    "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
3752                    "time_window_secs": secs,
3753                    "total_events": events.len(),
3754                    "ipc_calls_made": ipc_commands.len(),
3755                    "unique_commands": ipc_commands,
3756                    "dom_elements_changed": dom_changes,
3757                    "interactions": interaction_count,
3758                    "console_messages": console_messages,
3759                    "errors": error_count,
3760                });
3761                json_result(&result)
3762            }
3763        }
3764    }
3765}
3766
3767impl VictauriMcpHandler {
3768    /// Create a new handler backed by the given state and webview bridge.
3769    pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
3770        Self {
3771            state,
3772            bridge,
3773            subscriptions: Arc::new(Mutex::new(HashSet::new())),
3774            bridge_checked: Arc::new(AtomicBool::new(false)),
3775            timed_out_labels: Arc::new(Mutex::new(HashSet::new())),
3776        }
3777    }
3778
3779    pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
3780        self.state.privacy.is_tool_enabled(name)
3781    }
3782
3783    pub(crate) async fn execute_tool(
3784        &self,
3785        name: &str,
3786        args: serde_json::Value,
3787    ) -> Result<CallToolResult, rest::ToolCallError> {
3788        // Centralized authorization: resolve the canonical `tool.action` capability
3789        // and gate on it BEFORE dispatch, so every compound action is checked
3790        // uniformly (not just the ones whose handler remembers to). See `authz`.
3791        let capability = authz::canonical_capability(name, &args);
3792        if !self.state.privacy.is_call_allowed(name, &capability) {
3793            return Ok(tool_disabled(&capability));
3794        }
3795        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
3796        let start = std::time::Instant::now();
3797        tracing::debug!(tool = %name, "REST tool invocation started");
3798
3799        let result = match name {
3800            "eval_js" => {
3801                let p: EvalJsParams = Self::parse_args(args)?;
3802                self.eval_js(Parameters(p)).await
3803            }
3804            "dom_snapshot" => {
3805                let p: SnapshotParams = Self::parse_args(args)?;
3806                self.dom_snapshot(Parameters(p)).await
3807            }
3808            "find_elements" => {
3809                let p: FindElementsParams = Self::parse_args(args)?;
3810                self.find_elements(Parameters(p)).await
3811            }
3812            "invoke_command" => {
3813                let p: InvokeCommandParams = Self::parse_args(args)?;
3814                self.invoke_command(Parameters(p)).await
3815            }
3816            "screenshot" => {
3817                let p: ScreenshotParams = Self::parse_args(args)?;
3818                self.screenshot(Parameters(p)).await
3819            }
3820            "verify_state" => {
3821                let p: VerifyStateParams = Self::parse_args(args)?;
3822                self.verify_state(Parameters(p)).await
3823            }
3824            "detect_ghost_commands" => {
3825                let p: GhostCommandParams = Self::parse_args(args)?;
3826                self.detect_ghost_commands(Parameters(p)).await
3827            }
3828            "check_ipc_integrity" => {
3829                let p: IpcIntegrityParams = Self::parse_args(args)?;
3830                self.check_ipc_integrity(Parameters(p)).await
3831            }
3832            "wait_for" => {
3833                let p: WaitForParams = Self::parse_args(args)?;
3834                self.wait_for(Parameters(p)).await
3835            }
3836            "assert_semantic" => {
3837                let p: SemanticAssertParams = Self::parse_args(args)?;
3838                self.assert_semantic(Parameters(p)).await
3839            }
3840            "resolve_command" => {
3841                let p: ResolveCommandParams = Self::parse_args(args)?;
3842                self.resolve_command(Parameters(p)).await
3843            }
3844            "get_registry" => {
3845                let p: RegistryParams = Self::parse_args(args)?;
3846                self.get_registry(Parameters(p)).await
3847            }
3848            "app_state" => {
3849                let p: AppStateParams = Self::parse_args(args)?;
3850                self.app_state(Parameters(p)).await
3851            }
3852            "get_memory_stats" => self.get_memory_stats().await,
3853            "get_plugin_info" => self.get_plugin_info().await,
3854            "get_diagnostics" => {
3855                let p: DiagnosticsParams = Self::parse_args(args)?;
3856                self.get_diagnostics(Parameters(p)).await
3857            }
3858            "app_info" => self.app_info().await,
3859            "list_app_dir" => {
3860                let p: ListAppDirParams = Self::parse_args(args)?;
3861                self.list_app_dir(Parameters(p)).await
3862            }
3863            "read_app_file" => {
3864                let p: ReadAppFileParams = Self::parse_args(args)?;
3865                self.read_app_file(Parameters(p)).await
3866            }
3867            "query_db" => {
3868                let p: QueryDbParams = Self::parse_args(args)?;
3869                self.query_db(Parameters(p)).await
3870            }
3871            "interact" => {
3872                let p: InteractParams = Self::parse_args(args)?;
3873                self.interact(Parameters(p)).await
3874            }
3875            "input" => {
3876                let p: InputParams = Self::parse_args(args)?;
3877                self.input(Parameters(p)).await
3878            }
3879            "window" => {
3880                let p: WindowParams = Self::parse_args(args)?;
3881                self.window(Parameters(p)).await
3882            }
3883            "storage" => {
3884                let p: StorageParams = Self::parse_args(args)?;
3885                self.storage(Parameters(p)).await
3886            }
3887            "navigate" => {
3888                let p: NavigateParams = Self::parse_args(args)?;
3889                self.navigate(Parameters(p)).await
3890            }
3891            "recording" => {
3892                let p: RecordingParams = Self::parse_args(args)?;
3893                self.recording(Parameters(p)).await
3894            }
3895            "inspect" => {
3896                let p: InspectParams = Self::parse_args(args)?;
3897                self.inspect(Parameters(p)).await
3898            }
3899            "css" => {
3900                let p: CssParams = Self::parse_args(args)?;
3901                self.css(Parameters(p)).await
3902            }
3903            "route" => {
3904                let p: RouteParams = Self::parse_args(args)?;
3905                self.route(Parameters(p)).await
3906            }
3907            "trace" => {
3908                let p: TraceParams = Self::parse_args(args)?;
3909                self.trace(Parameters(p)).await
3910            }
3911            "animation" => {
3912                let p: AnimationParams = Self::parse_args(args)?;
3913                self.animation(Parameters(p)).await
3914            }
3915            "logs" => {
3916                let p: LogsParams = Self::parse_args(args)?;
3917                self.logs(Parameters(p)).await
3918            }
3919            "introspect" => {
3920                let p: IntrospectParams = Self::parse_args(args)?;
3921                self.introspect(Parameters(p)).await
3922            }
3923            "fault" => {
3924                let p: FaultParams = Self::parse_args(args)?;
3925                self.fault(Parameters(p)).await
3926            }
3927            "explain" => {
3928                let p: ExplainParams = Self::parse_args(args)?;
3929                self.explain(Parameters(p)).await
3930            }
3931            _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
3932        };
3933
3934        let elapsed = start.elapsed();
3935        tracing::debug!(
3936            tool = %name,
3937            elapsed_ms = elapsed.as_millis() as u64,
3938            "REST tool invocation completed"
3939        );
3940
3941        if self.state.privacy.redaction_enabled {
3942            Ok(Self::redact_result(result, &self.state.privacy))
3943        } else {
3944            Ok(result)
3945        }
3946    }
3947
3948    fn parse_args<T: serde::de::DeserializeOwned>(
3949        args: serde_json::Value,
3950    ) -> Result<T, rest::ToolCallError> {
3951        serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
3952    }
3953
3954    fn redact_result(
3955        mut result: CallToolResult,
3956        privacy: &crate::privacy::PrivacyConfig,
3957    ) -> CallToolResult {
3958        for item in &mut result.content {
3959            if let RawContent::Text(ref mut tc) = item.raw {
3960                tc.text = privacy.redact_output(&tc.text);
3961            }
3962        }
3963        result
3964    }
3965
3966    fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
3967        match dir.unwrap_or(AppDir::Data) {
3968            AppDir::Data => self.bridge.app_data_dir(),
3969            AppDir::Config => self.bridge.app_config_dir(),
3970            AppDir::Log => self.bridge.app_log_dir(),
3971            AppDir::LocalData => self.bridge.app_local_data_dir(),
3972        }
3973    }
3974
3975    /// Lexical (pre-existence) traversal guard for a user-supplied sub-path.
3976    ///
3977    /// Rejects absolute paths and any component that is `..` BEFORE the path is
3978    /// canonicalized. This is necessary because [`Self::safe_within`] relies on
3979    /// `canonicalize`, which errors on non-existent paths — so a traversal
3980    /// attempt against a missing target would otherwise be reported as
3981    /// "not found" (an info-leak oracle) rather than as traversal.
3982    fn lexical_safe(sub: &std::path::Path) -> Result<(), String> {
3983        use std::path::Component;
3984        if sub.is_absolute() {
3985            return Err("path traversal not allowed: absolute paths are rejected".to_string());
3986        }
3987        for component in sub.components() {
3988            match component {
3989                Component::ParentDir => {
3990                    return Err("path traversal not allowed: '..' is rejected".to_string());
3991                }
3992                Component::Prefix(_) | Component::RootDir => {
3993                    return Err(
3994                        "path traversal not allowed: absolute paths are rejected".to_string()
3995                    );
3996                }
3997                Component::CurDir | Component::Normal(_) => {}
3998            }
3999        }
4000        Ok(())
4001    }
4002
4003    fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
4004        let canon_base = std::fs::canonicalize(base)
4005            .map_err(|e| format!("cannot resolve base directory: {e}"))?;
4006        let canon_target = std::fs::canonicalize(target)
4007            .map_err(|e| format!("cannot resolve target path: {e}"))?;
4008        if !canon_target.starts_with(&canon_base) {
4009            return Err("path traversal not allowed".to_string());
4010        }
4011        Ok(())
4012    }
4013
4014    #[cfg(feature = "sqlite")]
4015    fn resolve_existing_db_path(
4016        roots: &[std::path::PathBuf],
4017        requested: &str,
4018    ) -> Result<std::path::PathBuf, String> {
4019        let candidate = std::path::Path::new(requested);
4020        if candidate.is_absolute() {
4021            if !candidate.exists() {
4022                return Err(format!("database not found: {requested}"));
4023            }
4024            if roots
4025                .iter()
4026                .any(|root| Self::safe_within(root, candidate).is_ok())
4027            {
4028                // Open the CANONICAL validated path, not the caller's literal absolute path,
4029                // so the DB is opened at exactly the containment-approved location — symmetric
4030                // with the relative branch below and closing the validate-canonical/open-lexical
4031                // TOCTOU on this branch (a same-privilege symlink swap between canonicalize and
4032                // open is the unavoidable residual, documented in security.md).
4033                let canonical = std::fs::canonicalize(candidate)
4034                    .map_err(|e| format!("cannot resolve database path: {e}"))?;
4035                return Ok(canonical);
4036            }
4037            return Err(format!(
4038                "absolute path '{requested}' is not within an allowed directory; \
4039                 register its parent via VictauriBuilder::db_search_paths"
4040            ));
4041        }
4042
4043        Self::lexical_safe(candidate)?;
4044        for root in roots {
4045            let resolved = root.join(candidate);
4046            if resolved.exists() {
4047                Self::safe_within(root, &resolved)?;
4048                // Open the CANONICAL validated path, not the lexical join, so the DB is opened
4049                // at exactly the path containment approved (closes the trivial validate-lexical
4050                // vs open-lexical TOCTOU; a same-privilege local symlink swap between
4051                // canonicalize and open is the unavoidable residual, documented in security.md).
4052                let canonical = std::fs::canonicalize(&resolved)
4053                    .map_err(|e| format!("cannot resolve database path: {e}"))?;
4054                return Ok(canonical);
4055            }
4056        }
4057
4058        let roots = roots
4059            .iter()
4060            .map(|root| root.display().to_string())
4061            .collect::<Vec<_>>()
4062            .join(", ");
4063        Err(format!(
4064            "database not found: {requested} (searched: {roots})"
4065        ))
4066    }
4067
4068    #[cfg(feature = "sqlite")]
4069    fn quote_sqlite_identifier(identifier: &str) -> String {
4070        format!("\"{}\"", identifier.replace('"', "\"\""))
4071    }
4072
4073    fn list_dir_recursive(
4074        dir: &std::path::Path,
4075        base: &std::path::Path,
4076        depth: u32,
4077        max_depth: u32,
4078        pattern: Option<&str>,
4079        entries: &mut Vec<serde_json::Value>,
4080    ) {
4081        if entries.len() >= MAX_DIR_ENTRIES {
4082            return;
4083        }
4084        let Ok(read_dir) = std::fs::read_dir(dir) else {
4085            return;
4086        };
4087        for entry in read_dir.flatten() {
4088            if entries.len() >= MAX_DIR_ENTRIES {
4089                return;
4090            }
4091            let path = entry.path();
4092            if path.is_symlink() {
4093                continue;
4094            }
4095            // `is_symlink` does not cover every redirecting filesystem object
4096            // (notably Windows directory junctions/reparse points). Canonical
4097            // containment is the actual boundary before metadata or recursion.
4098            if Self::safe_within(base, &path).is_err() {
4099                continue;
4100            }
4101            let name = entry.file_name().to_string_lossy().into_owned();
4102            let relative = path
4103                .strip_prefix(base)
4104                .unwrap_or(&path)
4105                .to_string_lossy()
4106                .into_owned();
4107
4108            if let Some(pat) = pattern
4109                && !Self::matches_glob(&name, pat)
4110                && !path.is_dir()
4111            {
4112                continue;
4113            }
4114
4115            let is_dir = path.is_dir();
4116            let meta = std::fs::metadata(&path).ok();
4117
4118            entries.push(serde_json::json!({
4119                "name": name,
4120                "path": relative,
4121                "is_dir": is_dir,
4122                "size": meta.as_ref().map(std::fs::Metadata::len),
4123                "modified": meta.as_ref()
4124                    .and_then(|m| m.modified().ok())
4125                    .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
4126                        .unwrap_or_default().as_secs()),
4127            }));
4128
4129            if is_dir && depth < max_depth {
4130                Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
4131            }
4132        }
4133    }
4134
4135    fn matches_glob(name: &str, pattern: &str) -> bool {
4136        if pattern == "*" {
4137            return true;
4138        }
4139        if let Some(suffix) = pattern.strip_prefix("*.") {
4140            return name.ends_with(&format!(".{suffix}"));
4141        }
4142        if let Some(prefix) = pattern.strip_suffix("*") {
4143            return name.starts_with(prefix);
4144        }
4145        name == pattern
4146    }
4147
4148    /// Probe every window's JS bridge and report which are introspectable. A
4149    /// visible window that fails to respond almost always lacks the
4150    /// `victauri:default` capability — Tauri's permission ACL silently blocks
4151    /// the bridge's callback IPC, so eval/dom/animation tools see nothing. This
4152    /// turns that silent dead-end into an actionable, up-front diagnosis.
4153    async fn window_introspectability(&self) -> CallToolResult {
4154        let labels = self.bridge.list_window_labels();
4155        let states = self.bridge.get_window_states(None);
4156        let mut report = Vec::with_capacity(labels.len());
4157        let mut blind = 0usize;
4158        for label in &labels {
4159            let visible = states.iter().find(|s| &s.label == label).map(|s| s.visible);
4160            let introspectable = self.probe_bridge(Some(label)).await.is_ok();
4161            if !introspectable {
4162                blind += 1;
4163            }
4164            let note = if introspectable {
4165                "ok — Victauri JS bridge is responding".to_string()
4166            } else if visible == Some(true) {
4167                format!(
4168                    "NOT introspectable although the window is visible — almost certainly missing \
4169                     the Victauri capability. Add \"victauri:default\" to the capability file \
4170                     (src-tauri/capabilities/*.json) whose \"windows\" list includes \"{label}\", \
4171                     then rebuild. Capabilities are baked at compile time, so a rebuild is required."
4172                )
4173            } else {
4174                "NOT introspectable (window is hidden and/or has no bridge) — show the window to \
4175                 confirm, and ensure its capability includes \"victauri:default\", then rebuild."
4176                    .to_string()
4177            };
4178            report.push(serde_json::json!({
4179                "label": label,
4180                "visible": visible,
4181                "introspectable": introspectable,
4182                "note": note,
4183            }));
4184        }
4185        let hint = if blind > 0 {
4186            "Windows with introspectable:false have no working Victauri JS bridge — eval_js, \
4187             dom_snapshot, animation, find_elements, etc. cannot see them. The usual cause is a \
4188             missing \"victauri:default\" capability for that window: Tauri's per-window permission \
4189             ACL silently blocks the bridge's callback IPC. This capability is required per window, \
4190             not just for the main window. (Note: probing a blind window takes ~2s each.)"
4191        } else {
4192            "All windows are introspectable."
4193        };
4194        json_result(&serde_json::json!({
4195            "windows": report,
4196            "introspectable_count": labels.len().saturating_sub(blind),
4197            "blind_count": blind,
4198            "hint": hint,
4199        }))
4200    }
4201
4202    async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
4203        match self.eval_with_return(code, webview_label).await {
4204            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
4205            Err(e) => tool_error(e),
4206        }
4207    }
4208
4209    async fn eval_with_return(
4210        &self,
4211        code: &str,
4212        webview_label: Option<&str>,
4213    ) -> Result<String, String> {
4214        self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
4215            .await
4216    }
4217
4218    /// Atomically reserve a pending-eval slot under a SINGLE lock: reject if the map is
4219    /// already at the concurrency ceiling, otherwise insert. This makes `MAX_PENDING_EVALS`
4220    /// a TRUE hard ceiling — a separate check-then-insert races (concurrent callers all pass
4221    /// a stale check, then each inserts, blowing past the cap). On a saturated map it also
4222    /// fails fast (before any eval is injected) with the real "too many concurrent" cause
4223    /// rather than letting a probe burn its full timeout.
4224    async fn reserve_pending(
4225        &self,
4226        id: &str,
4227        tx: tokio::sync::oneshot::Sender<String>,
4228    ) -> Result<(), String> {
4229        let mut pending = self.state.pending_evals.lock().await;
4230        if pending.len() >= MAX_PENDING_EVALS {
4231            return Err(format!(
4232                "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
4233            ));
4234        }
4235        pending.insert(id.to_string(), tx);
4236        Ok(())
4237    }
4238
4239    async fn probe_bridge(&self, webview_label: Option<&str>) -> Result<(), String> {
4240        let id = uuid::Uuid::new_v4().to_string();
4241        let (tx, rx) = tokio::sync::oneshot::channel();
4242        self.reserve_pending(&id, tx).await?;
4243        let id_js = js_string(&id);
4244        let probe = format!(
4245            r#"(async()=>{{await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',{{id:{id_js},result:'"probe_ok"'}});}})();"#
4246        );
4247        if let Err(e) = self.bridge.eval_webview(webview_label, &probe) {
4248            self.state.pending_evals.lock().await.remove(&id);
4249            return Err(format!("eval injection failed: {e}"));
4250        }
4251        if let Ok(Ok(_)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await {
4252            Ok(())
4253        } else {
4254            self.state.pending_evals.lock().await.remove(&id);
4255            let label = webview_label.unwrap_or("default");
4256            Err(format!(
4257                "bridge not responding on window '{label}' — the window may be hidden, \
4258                 missing the victauri capability, or the JS bridge is not loaded (e.g. the page \
4259                 failed to load: a dev-server connection-refused or blank error page has no JS \
4260                 bridge — check the window with the `screenshot` tool, which works regardless)"
4261            ))
4262        }
4263    }
4264
4265    async fn eval_with_return_timeout(
4266        &self,
4267        code: &str,
4268        webview_label: Option<&str>,
4269        timeout: std::time::Duration,
4270    ) -> Result<String, String> {
4271        // The hard concurrency ceiling is enforced atomically at every reservation
4272        // (`reserve_pending`, used by both the probe and the real eval below) — NOT with a
4273        // separate early check, which races: concurrent callers would all pass a stale
4274        // `len()` read before any of them inserts. The probe is the first reservation, so a
4275        // saturated map is rejected fast (before any eval is injected) with the real "too
4276        // many concurrent" cause.
4277
4278        // Wait for the JS bridge ready signal (sent on bridge init) before
4279        // attempting evals.  For explicitly targeted windows the probe
4280        // mechanism is still used because the ready signal only proves that
4281        // *some* webview's bridge loaded — not necessarily the targeted one.
4282        if !self
4283            .state
4284            .bridge_ready
4285            .load(std::sync::atomic::Ordering::Acquire)
4286        {
4287            let notified = self.state.bridge_notify.notified();
4288            if !self
4289                .state
4290                .bridge_ready
4291                .load(std::sync::atomic::Ordering::Acquire)
4292            {
4293                let _ = tokio::time::timeout(std::time::Duration::from_secs(5), notified).await;
4294            }
4295        }
4296
4297        // Reserved sentinel key for the default (unlabeled) window — cannot
4298        // collide with a real label.
4299        let label_key =
4300            webview_label.map_or_else(|| "\u{1}__default__".to_string(), str::to_string);
4301
4302        // Liveness probe before EVERY eval — on the DEFAULT window as well as
4303        // labeled ones. The probe is a tiny round-trip that returns in ~ms on a
4304        // healthy bridge and fails fast (~2s) on a dead/hung/reloading one, turning
4305        // a full-timeout hang (e.g. 30s) into an immediate, clear "bridge not
4306        // responding" error. This was the #1 live-4DA friction: a webview that
4307        // reloads mid-session (HMR) made the very next tool call hang the full
4308        // timeout, and the DEFAULT window — the most common target — was never
4309        // probed at all. Probing every call (not once-cached) is what guarantees
4310        // *zero* 30s hangs even across repeated reloads; the healthy-path cost is a
4311        // single sub-millisecond localhost round-trip, negligible against the value
4312        // of never stalling an agent into a CDP fallback. (A saturated pending-eval
4313        // map is already rejected above, before this probe.)
4314        let prev_timed_out = self.timed_out_labels.lock().await.remove(&label_key);
4315        if let Err(e) = self.probe_bridge(webview_label).await {
4316            return Err(if prev_timed_out {
4317                format!(
4318                    "{e} (a previous eval on this window also timed out — the webview \
4319                     likely reloaded or the app stopped responding)"
4320                )
4321            } else {
4322                e
4323            });
4324        }
4325
4326        let id = uuid::Uuid::new_v4().to_string();
4327        let (tx, rx) = tokio::sync::oneshot::channel();
4328        self.reserve_pending(&id, tx).await?;
4329
4330        // Auto-prepend `return` so bare expressions produce a value — but ONLY
4331        // for single expressions. Multi-statement blocks (or code containing an
4332        // explicit `return`) are used as-is. Prepending `return` to a statement
4333        // block like `foo(); return bar()` would parse as `return foo();` and
4334        // silently discard everything after the first statement (issue: core
4335        // primitive returned wrong/undefined values for "do X, then return Y").
4336        let code = if should_prepend_return(code) {
4337            format!("return {}", code.trim())
4338        } else {
4339            code.trim().to_string()
4340        };
4341
4342        let id_js = js_string(&id);
4343
4344        // Fail fast on a SYNTAX error instead of hanging for the full timeout (audit /
4345        // red-team "malformed eval consumes the full 30s"). The user code is inlined into
4346        // the script below; if it has a parse error the WHOLE script fails to parse and the
4347        // try/catch never runs, so the callback never fires. We cannot wrap the code in
4348        // `new Function`/`AsyncFunction` to surface the SyntaxError, because dynamic code
4349        // generation is gated by the same `unsafe-eval` CSP that blocks `eval()` — which is
4350        // exactly why the bridge uses an inline async-IIFE in the first place. Instead an
4351        // independent watchdog (which always parses) reports a parse error quickly: the
4352        // user-code script sets a `started` flag at its very top, so a script that fails to
4353        // parse never sets it. A valid-but-slow eval (e.g. a `wait_for` poll) sets `started`
4354        // immediately and is left to run to the real timeout — the watchdog only fires when
4355        // the code never began executing.
4356        let watchdog = format!(
4357            r"
4358            (function () {{
4359                window.__VIC_EVAL__ = window.__VIC_EVAL__ || {{}};
4360                var s = (window.__VIC_EVAL__[{id_js}] =
4361                    window.__VIC_EVAL__[{id_js}] || {{ started: false, done: false }});
4362                setTimeout(function () {{
4363                    if (s.started || s.done) return;
4364                    s.done = true;
4365                    try {{
4366                        window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4367                            id: {id_js},
4368                            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)' }})
4369                        }});
4370                    }} catch (e) {{}}
4371                    delete window.__VIC_EVAL__[{id_js}];
4372                }}, {PARSE_WATCHDOG_MS});
4373            }})();
4374            "
4375        );
4376
4377        let inject = format!(
4378            r"
4379            (async () => {{
4380                var __s = (window.__VIC_EVAL__ && window.__VIC_EVAL__[{id_js}]) || null;
4381                if (__s) __s.started = true;
4382                try {{
4383                    const __result = await (async () => {{ {code} }})();
4384                    if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }}
4385                    const __type = __result === undefined ? 'undefined'
4386                        : __result === null ? 'null' : 'value';
4387                    const __val = __type === 'undefined' ? null
4388                        : __type === 'null' ? null : __result;
4389                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4390                        id: {id_js},
4391                        result: JSON.stringify({{ __victauri_ok: __val, __victauri_type: __type }})
4392                    }});
4393                }} catch (e) {{
4394                    if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }}
4395                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4396                        id: {id_js},
4397                        result: JSON.stringify({{ __victauri_err: String(e && e.message || e) }})
4398                    }});
4399                }}
4400            }})();
4401            "
4402        );
4403
4404        // Inject the watchdog first so it is armed before the user code runs. Order is not
4405        // critical (the user-code script no-ops the watchdog state if it ran first), but
4406        // arming first minimises the window.
4407        if let Err(e) = self.bridge.eval_webview(webview_label, &watchdog) {
4408            self.state.pending_evals.lock().await.remove(&id);
4409            return Err(format!("eval injection failed: {e}"));
4410        }
4411        if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
4412            self.state.pending_evals.lock().await.remove(&id);
4413            return Err(format!("eval injection failed: {e}"));
4414        }
4415
4416        match tokio::time::timeout(timeout, rx).await {
4417            Ok(Ok(raw)) => {
4418                self.check_bridge_version_once();
4419                if raw.len() > MAX_EVAL_RESULT_LEN {
4420                    return Err(format!(
4421                        "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
4422                        raw.len()
4423                    ));
4424                }
4425                unwrap_eval_envelope(raw)
4426            }
4427            Ok(Err(_)) => Err("eval callback channel closed".to_string()),
4428            Err(_) => {
4429                self.state.pending_evals.lock().await.remove(&id);
4430                // Mark this window so the NEXT eval does a fast liveness probe —
4431                // if the bridge is gone (reloaded/crashed) the next call fails in
4432                // ~2s instead of blocking the full timeout again.
4433                self.timed_out_labels.lock().await.insert(label_key.clone());
4434                Err(format!(
4435                    "eval timed out after {}s — the code began executing but never resolved. \
4436                     (A syntax/parse error would have failed fast via the parse watchdog, so \
4437                     this is NOT a parse error.) Common causes: an unresolved promise, an \
4438                     infinite loop, an `await` on something that never settles, or the webview \
4439                     reloaded / the app stopped responding mid-eval. If the app may have \
4440                     navigated or crashed, retry (the next call fails fast if the bridge is \
4441                     gone).",
4442                    timeout.as_secs()
4443                ))
4444            }
4445        }
4446    }
4447
4448    #[cfg(feature = "sqlite")]
4449    async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
4450        // Roots: configured db_search_paths first, then app directories.
4451        let mut roots: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
4452        for d in [
4453            self.bridge.app_data_dir(),
4454            self.bridge.app_local_data_dir(),
4455            self.bridge.app_config_dir(),
4456        ]
4457        .into_iter()
4458        .flatten()
4459        {
4460            roots.push(d);
4461        }
4462
4463        let path = if let Some(p) = db_path {
4464            Self::resolve_existing_db_path(&roots, p)?
4465        } else {
4466            // Configured db_search_paths are EXCLUSIVE when set (don't fall back to the
4467            // OS app dirs that hold WebView internals); WebView/engine internal stores are
4468            // excluded and the largest real candidate wins (audit / red-team "wrong DB").
4469            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
4470                roots.clone()
4471            } else {
4472                self.state.db_search_paths.clone()
4473            };
4474            crate::database::select_app_database(&select_dirs)?
4475        };
4476        let path_str = path
4477            .to_str()
4478            .ok_or_else(|| "invalid path encoding".to_string())?
4479            .to_string();
4480
4481        tokio::task::spawn_blocking(move || {
4482            let conn = rusqlite::Connection::open_with_flags(
4483                &path_str,
4484                rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
4485            )
4486            .map_err(|e| format!("cannot open database: {e}"))?;
4487            conn.set_limit(
4488                rusqlite::limits::Limit::SQLITE_LIMIT_LENGTH,
4489                MAX_DB_HEALTH_CELL_BYTES,
4490            );
4491            let started = std::time::Instant::now();
4492            let timed_out = Arc::new(AtomicBool::new(false));
4493            let timeout_marker = Arc::clone(&timed_out);
4494            conn.progress_handler(
4495                DB_HEALTH_PROGRESS_OPS,
4496                Some(move || {
4497                    let expired = started.elapsed() >= DB_HEALTH_TIMEOUT;
4498                    if expired {
4499                        timeout_marker.store(true, Ordering::Relaxed);
4500                    }
4501                    expired
4502                }),
4503            );
4504            // Hard wall-clock backstop for single long ops (e.g. integrity_check / per-table
4505            // count(*) on a huge DB) that the opcode-sampling progress handler under-counts.
4506            let _interrupt = crate::database::InterruptGuard::arm(&conn, DB_HEALTH_TIMEOUT);
4507
4508            let journal_mode: String = conn
4509                .pragma_query_value(None, "journal_mode", |r| r.get(0))
4510                .unwrap_or_else(|_| "unknown".to_string());
4511
4512            let page_count: i64 = conn
4513                .pragma_query_value(None, "page_count", |r| r.get(0))
4514                .unwrap_or(0);
4515
4516            let page_size: i64 = conn
4517                .pragma_query_value(None, "page_size", |r| r.get(0))
4518                .unwrap_or(0);
4519
4520            let freelist_count: i64 = conn
4521                .pragma_query_value(None, "freelist_count", |r| r.get(0))
4522                .unwrap_or(0);
4523
4524            let wal_checkpoint: &str = if journal_mode == "wal" {
4525                "not run (read-only diagnostics)"
4526            } else {
4527                "n/a (not WAL mode)"
4528            };
4529
4530            let integrity: String = conn
4531                .pragma_query_value(None, "quick_check", |r| r.get(0))
4532                .unwrap_or_else(|_| "failed".to_string());
4533
4534            let db_size_bytes = page_count * page_size;
4535            let db_size_mb = db_size_bytes as f64 / (1024.0 * 1024.0);
4536
4537            let mut tables = Vec::new();
4538            let mut table_bytes = 0usize;
4539            let mut tables_truncated = false;
4540            if let Ok(mut stmt) =
4541                conn.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
4542                && let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0))
4543            {
4544                for name in rows.flatten() {
4545                    if tables.len() >= MAX_DB_HEALTH_TABLES
4546                        || table_bytes.saturating_add(name.len()) > MAX_DB_HEALTH_TABLE_BYTES
4547                    {
4548                        tables_truncated = true;
4549                        break;
4550                    }
4551                    table_bytes = table_bytes.saturating_add(name.len());
4552                    let identifier = Self::quote_sqlite_identifier(&name);
4553                    let count: i64 = conn
4554                        .query_row(&format!("SELECT count(*) FROM {identifier}"), [], |r| {
4555                            r.get(0)
4556                        })
4557                        .unwrap_or(0);
4558                    tables.push(serde_json::json!({
4559                        "name": name,
4560                        "row_count": count,
4561                    }));
4562                }
4563            }
4564            if timed_out.load(Ordering::Relaxed) {
4565                return Err(format!(
4566                    "database diagnostics timed out after {} ms",
4567                    DB_HEALTH_TIMEOUT.as_millis()
4568                ));
4569            }
4570
4571            Ok(serde_json::json!({
4572                "database": path_str,
4573                "journal_mode": journal_mode,
4574                "page_count": page_count,
4575                "page_size": page_size,
4576                "db_size_mb": (db_size_mb * 100.0).round() / 100.0,
4577                "freelist_count": freelist_count,
4578                "wal_checkpoint": wal_checkpoint,
4579                "integrity_check": integrity,
4580                "tables": tables,
4581                "tables_truncated": tables_truncated,
4582            }))
4583        })
4584        .await
4585        .map_err(|e| format!("db health task failed: {e}"))?
4586    }
4587
4588    fn check_bridge_version_once(&self) {
4589        if self.bridge_checked.swap(true, Ordering::Relaxed) {
4590            return;
4591        }
4592        let handler = self.clone();
4593        tokio::spawn(async move {
4594            match handler
4595                .eval_with_return_timeout(
4596                    "window.__VICTAURI__?.version",
4597                    None,
4598                    std::time::Duration::from_secs(5),
4599                )
4600                .await
4601            {
4602                Ok(v) => {
4603                    let v = v.trim_matches('"');
4604                    if v == BRIDGE_VERSION {
4605                        tracing::debug!("Bridge version verified: {v}");
4606                    } else {
4607                        tracing::warn!(
4608                            "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
4609                        );
4610                    }
4611                }
4612                Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
4613            }
4614        });
4615    }
4616}
4617
4618const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
4619It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
4620(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
4621(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
4622\n\nBACKEND tools (direct Rust access, no webview needed): \
4623'app_info' (app config, directory paths, discovered databases, process info), \
4624'list_app_dir' (browse app data/config/log directories), \
4625'read_app_file' (read files from app directories), \
4626'query_db' (read-only SQLite queries with auto-discovery). \
4627\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
4628'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
4629capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
4630Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
4631capability/security auditing, database diagnostics, plugin state, child process enumeration, \
4632task tracking, and automatic Tauri event bus monitoring. \
4633'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
4634drops, and response corruption into Tauri commands at the Rust layer. \
4635'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
4636activity across IPC + DOM + console + network + window events into a coherent narrative. \
4637\n\nWEBVIEW tools: \
4638'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
4639'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
4640'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
4641\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
4642\n\nCOMPOUND tools with an 'action' parameter: \
4643'window' (get_state, list, manage, resize, move_to, set_title), \
4644'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
4645set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
4646get_events, events_between, get_replay, export, import, replay), \
4647'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
4648\n\nOTHER: verify_state, wait_for (incl. 'expression'/'event' conditions to await \
4649async backend work to true completion), assert_semantic, resolve_command, \
4650app_state (app-defined backend state probes), \
4651get_memory_stats, get_plugin_info, get_diagnostics.";
4652
4653impl ServerHandler for VictauriMcpHandler {
4654    fn get_info(&self) -> ServerInfo {
4655        // NOTE: we advertise `resources` (read) but NOT `resources.subscribe`. A real
4656        // server-initiated `notifications/resources/updated` push was never implemented
4657        // (subscribe/unsubscribe only record intent in memory; nothing emits updates), and
4658        // the default stateless transport has no SSE channel to push over anyway. Advertising
4659        // a subscribe capability we cannot honour misleads clients — read resources on demand.
4660        ServerInfo::new(
4661            ServerCapabilities::builder()
4662                .enable_tools()
4663                .enable_resources()
4664                .build(),
4665        )
4666        .with_instructions(SERVER_INSTRUCTIONS)
4667    }
4668
4669    async fn list_tools(
4670        &self,
4671        _request: Option<PaginatedRequestParams>,
4672        _context: RequestContext<RoleServer>,
4673    ) -> Result<ListToolsResult, ErrorData> {
4674        let all_tools = Self::tool_router().list_all();
4675        let filtered: Vec<Tool> = all_tools
4676            .into_iter()
4677            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
4678            .collect();
4679        Ok(ListToolsResult {
4680            tools: filtered,
4681            ..Default::default()
4682        })
4683    }
4684
4685    async fn call_tool(
4686        &self,
4687        request: CallToolRequestParams,
4688        context: RequestContext<RoleServer>,
4689    ) -> Result<CallToolResult, ErrorData> {
4690        let tool_name: String = request.name.as_ref().to_owned();
4691        // Centralized authorization: gate on the canonical `tool.action` capability
4692        // resolved from the call arguments, matching the REST path in `execute_tool`.
4693        let args_value = serde_json::Value::Object(request.arguments.clone().unwrap_or_default());
4694        let capability = authz::canonical_capability(&tool_name, &args_value);
4695        if !self.state.privacy.is_call_allowed(&tool_name, &capability) {
4696            tracing::debug!(tool = %tool_name, capability = %capability, "tool call blocked by privacy config");
4697            return Ok(tool_disabled(&capability));
4698        }
4699        self.state
4700            .tool_invocations
4701            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4702        let start = std::time::Instant::now();
4703        tracing::debug!(tool = %tool_name, "tool invocation started");
4704        let ctx = ToolCallContext::new(self, request, context);
4705        let result = Self::tool_router().call(ctx).await;
4706        let elapsed = start.elapsed();
4707        tracing::debug!(
4708            tool = %tool_name,
4709            elapsed_ms = elapsed.as_millis() as u64,
4710            is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
4711            "tool invocation completed"
4712        );
4713
4714        // Centralized output redaction: apply to all text content so no
4715        // individual tool can accidentally leak secrets.
4716        if self.state.privacy.redaction_enabled {
4717            result.map(|mut r| {
4718                for item in &mut r.content {
4719                    if let RawContent::Text(ref mut tc) = item.raw {
4720                        tc.text = self.state.privacy.redact_output(&tc.text);
4721                    }
4722                }
4723                r
4724            })
4725        } else {
4726            result
4727        }
4728    }
4729
4730    fn get_tool(&self, name: &str) -> Option<Tool> {
4731        if !self.state.privacy.is_tool_enabled(name) {
4732            return None;
4733        }
4734        Self::tool_router().get(name).cloned()
4735    }
4736
4737    async fn list_resources(
4738        &self,
4739        _request: Option<PaginatedRequestParams>,
4740        _context: RequestContext<RoleServer>,
4741    ) -> Result<ListResourcesResult, ErrorData> {
4742        Ok(ListResourcesResult {
4743            resources: vec![
4744                RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
4745                    .with_description(
4746                        "Live IPC call log — all commands invoked between frontend and backend",
4747                    )
4748                    .with_mime_type("application/json")
4749                    .no_annotation(),
4750                RawResource::new(RESOURCE_URI_WINDOWS, "windows")
4751                    .with_description(
4752                        "Current state of all Tauri windows — position, size, visibility, focus",
4753                    )
4754                    .with_mime_type("application/json")
4755                    .no_annotation(),
4756                RawResource::new(RESOURCE_URI_STATE, "state")
4757                    .with_description(
4758                        "Victauri plugin state — event count, registered commands, memory stats",
4759                    )
4760                    .with_mime_type("application/json")
4761                    .no_annotation(),
4762            ],
4763            ..Default::default()
4764        })
4765    }
4766
4767    async fn read_resource(
4768        &self,
4769        request: ReadResourceRequestParams,
4770        _context: RequestContext<RoleServer>,
4771    ) -> Result<ReadResourceResult, ErrorData> {
4772        let uri = &request.uri;
4773        // Resources bypass the tool dispatcher, so they must apply the same privacy
4774        // gate themselves (audit B1): a strict profile that blocks log/window reads
4775        // as tools must not be able to read the same data via a resource.
4776        if let Some(cap) = resource_required_capability(uri.as_str())
4777            && !self.state.privacy.is_tool_enabled(cap)
4778        {
4779            return Err(ErrorData::invalid_request(
4780                format!("resource {uri} is not permitted by the current privacy configuration"),
4781                None,
4782            ));
4783        }
4784        let json = match uri.as_str() {
4785            RESOURCE_URI_IPC_LOG => {
4786                // Use the body-free, capped projection — NOT the full body-carrying
4787                // getIpcLog(). On a busy app the full log blows the eval result cap, the
4788                // eval fails, and we silently fall back to the Rust event_log (which is
4789                // itself default-window-drained) — serving a subset that looks complete.
4790                // trimmed_log_js bounds entries + truncates oversized fields so the
4791                // resource stays correct under load. (Matches the `logs ipc` tool.)
4792                let code = trimmed_log_js("window.__VICTAURI__?.getIpcLog()", DEFAULT_LOG_LIMIT);
4793                if let Ok(json) = self.eval_with_return(&code, None).await {
4794                    json
4795                } else {
4796                    let calls = self.state.event_log.ipc_calls();
4797                    serde_json::to_string_pretty(&calls)
4798                        .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4799                }
4800            }
4801            RESOURCE_URI_WINDOWS => {
4802                let states = self.bridge.get_window_states(None);
4803                serde_json::to_string_pretty(&states)
4804                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4805            }
4806            RESOURCE_URI_STATE => {
4807                let state_json = serde_json::json!({
4808                    "events_captured": self.state.event_log.len(),
4809                    "commands_registered": self.state.registry.count(),
4810                    "memory": crate::memory::current_stats(),
4811                    "port": self.state.port.load(Ordering::Relaxed),
4812                });
4813                serde_json::to_string_pretty(&state_json)
4814                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4815            }
4816            _ => {
4817                return Err(ErrorData::resource_not_found(
4818                    format!("unknown resource: {uri}"),
4819                    None,
4820                ));
4821            }
4822        };
4823
4824        let json = if self.state.privacy.redaction_enabled {
4825            self.state.privacy.redact_output(&json)
4826        } else {
4827            json
4828        };
4829
4830        Ok(ReadResourceResult::new(vec![ResourceContents::text(
4831            json, uri,
4832        )]))
4833    }
4834
4835    async fn subscribe(
4836        &self,
4837        request: SubscribeRequestParams,
4838        _context: RequestContext<RoleServer>,
4839    ) -> Result<(), ErrorData> {
4840        let uri = &request.uri;
4841        // Same privacy gate as read_resource (audit B1) — don't let a blocked
4842        // resource be subscribed to for push updates.
4843        if let Some(cap) = resource_required_capability(uri.as_str())
4844            && !self.state.privacy.is_tool_enabled(cap)
4845        {
4846            return Err(ErrorData::invalid_request(
4847                format!("resource {uri} is not permitted by the current privacy configuration"),
4848                None,
4849            ));
4850        }
4851        match uri.as_str() {
4852            RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
4853                self.subscriptions.lock().await.insert(uri.clone());
4854                tracing::info!("Client subscribed to resource: {uri}");
4855                Ok(())
4856            }
4857            _ => Err(ErrorData::resource_not_found(
4858                format!("unknown resource: {uri}"),
4859                None,
4860            )),
4861        }
4862    }
4863
4864    async fn unsubscribe(
4865        &self,
4866        request: UnsubscribeRequestParams,
4867        _context: RequestContext<RoleServer>,
4868    ) -> Result<(), ErrorData> {
4869        self.subscriptions.lock().await.remove(&request.uri);
4870        tracing::info!("Client unsubscribed from resource: {}", request.uri);
4871        Ok(())
4872    }
4873}
4874
4875/// Build a JS expression that takes an array of log entries (`source_expr`),
4876/// keeps at most `limit` of the most recent, and truncates any per-entry field
4877/// larger than [`MAX_LOG_FIELD_BYTES`]. This keeps IPC/network log results under
4878/// the eval size cap on busy apps where individual entries carry large bodies.
4879///
4880/// The returned code is a complete `return (...)` statement.
4881fn trimmed_log_js(source_expr: &str, limit: usize) -> String {
4882    let mb = MAX_LOG_FIELD_BYTES;
4883    format!(
4884        r"return (function() {{
4885            var MB = {mb};
4886            function trimField(v) {{
4887                if (typeof v === 'string') {{
4888                    return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
4889                }}
4890                if (v && typeof v === 'object') {{
4891                    var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }}
4892                    if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }}
4893                }}
4894                return v;
4895            }}
4896            function trimEntry(e) {{
4897                if (e == null || typeof e !== 'object') return e;
4898                var out = Array.isArray(e) ? [] : {{}};
4899                for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) out[k] = trimField(e[k]); }}
4900                return out;
4901            }}
4902            var arr = {source_expr} || [];
4903            if (arr.length > {limit}) arr = arr.slice(-{limit});
4904            return arr.map(trimEntry);
4905        }})()"
4906    )
4907}
4908
4909/// Unwrap the `{"__victauri_ok": <val>, "__victauri_type": <t>}` (or
4910/// `{"__victauri_err": <msg>}`) envelope produced by the eval bridge into the
4911/// value/error string returned to callers.
4912///
4913/// Parsing uses `serde_json`'s default recursion limit (it is intentionally NOT
4914/// disabled — an unbounded recursive parse of a pathologically deep result
4915/// overflows the worker thread stack and crashes the host). When the parse
4916/// fails because the value is too deeply nested, the envelope is stripped by
4917/// string slicing (no recursion) so the actual value is still returned rather
4918/// than leaking the raw envelope string.
4919fn unwrap_eval_envelope(raw: String) -> Result<String, String> {
4920    if let Ok(envelope) = serde_json::from_str::<serde_json::Value>(&raw) {
4921        if let Some(err) = envelope.get("__victauri_err") {
4922            return Err(format!(
4923                "JavaScript error: {}",
4924                err.as_str().unwrap_or("unknown error")
4925            ));
4926        }
4927        if envelope.get("__victauri_ok").is_some() {
4928            let js_type = envelope
4929                .get("__victauri_type")
4930                .and_then(|t| t.as_str())
4931                .unwrap_or("value");
4932            return match js_type {
4933                "undefined" => Ok("undefined".to_string()),
4934                "null" => Ok("null".to_string()),
4935                _ => Ok(serde_json::to_string(&envelope["__victauri_ok"])
4936                    .unwrap_or_else(|_| "null".to_string())),
4937            };
4938        }
4939    }
4940    // Fallback for results too deeply nested for the recursion-limited parser. `rfind` is
4941    // correct here: the wrapper appends `,"__victauri_type":"<type>"}` AFTER the entire payload,
4942    // so the real delimiter is structurally the LAST occurrence — a nested object key of the same
4943    // name appears earlier, and inside a string payload the quotes are escaped (`\"`), so neither
4944    // can be the last match. A hostile page therefore can't shift the slice boundary.
4945    if let Some(after) = raw.strip_prefix(r#"{"__victauri_ok":"#)
4946        && let Some(idx) = after.rfind(r#","__victauri_type":"#)
4947    {
4948        return Ok(after[..idx].to_string());
4949    }
4950    if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) {
4951        let msg = after.trim_end_matches('}').trim_matches('"');
4952        return Err(format!("JavaScript error: {msg}"));
4953    }
4954    Ok(raw)
4955}
4956
4957/// Statement keywords where a leading `return` would be a syntax error.
4958const STMT_STARTS: &[&str] = &[
4959    "return ",
4960    "return;",
4961    "return\n",
4962    "return\t",
4963    "if ",
4964    "if(",
4965    "for ",
4966    "for(",
4967    "while ",
4968    "while(",
4969    "switch ",
4970    "switch(",
4971    "try ",
4972    "try{",
4973    "const ",
4974    "let ",
4975    "var ",
4976    "function ",
4977    "function(",
4978    "function*",
4979    "class ",
4980    "throw ",
4981    "do ",
4982    "do{",
4983    "{",
4984    "async function",
4985    "debugger",
4986];
4987
4988/// String/template/comment scan state for [`should_prepend_return`].
4989#[derive(PartialEq, Clone, Copy)]
4990enum ScanState {
4991    Code,
4992    SingleQuote,
4993    DoubleQuote,
4994    Template,
4995}
4996
4997/// Decide whether to wrap `code` with a leading `return`.
4998///
4999/// Only a single bare expression should get `return` prepended. Code that is a
5000/// multi-statement block, contains an explicit top-level `return`, or starts
5001/// with a statement keyword is used as-is — prepending `return` to such code
5002/// would execute only the first statement and silently discard the rest.
5003///
5004/// The scan is string/template/comment-aware and only treats a `;` or an
5005/// explicit `return` token as significant when it occurs at bracket depth 0
5006/// outside of any string, template literal, or comment.
5007fn should_prepend_return(code: &str) -> bool {
5008    use ScanState::{Code, DoubleQuote, SingleQuote, Template};
5009
5010    let code = code.trim();
5011    if code.is_empty() {
5012        return false;
5013    }
5014
5015    if STMT_STARTS.iter().any(|k| code.starts_with(k)) {
5016        return false;
5017    }
5018
5019    let bytes = code.as_bytes();
5020    let mut i = 0;
5021    let mut depth: i32 = 0;
5022    let mut state = ScanState::Code;
5023
5024    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$';
5025    // Is there a top-level `return` token starting at byte `i` (word-bounded)?
5026    let is_return_token = |i: usize| -> bool {
5027        let prev_ok = i == 0 || !is_ident(bytes[i - 1]);
5028        prev_ok
5029            && code[i..].starts_with("return")
5030            && bytes.get(i + 6).copied().is_none_or(|b| !is_ident(b))
5031    };
5032
5033    while i < bytes.len() {
5034        let c = bytes[i];
5035        match state {
5036            Code => match c {
5037                b'\'' => state = SingleQuote,
5038                b'"' => state = DoubleQuote,
5039                b'`' => state = Template,
5040                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => {
5041                    while i < bytes.len() && bytes[i] != b'\n' {
5042                        i += 1;
5043                    }
5044                    continue;
5045                }
5046                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
5047                    i += 2;
5048                    while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
5049                        i += 1;
5050                    }
5051                    i += 2;
5052                    continue;
5053                }
5054                b'(' | b'[' | b'{' => depth += 1,
5055                b')' | b']' | b'}' => depth -= 1,
5056                // A top-level `;` with more code after it == multi-statement.
5057                b';' if depth <= 0 && !code[i + 1..].trim().is_empty() => return false,
5058                // An explicit top-level `return` token means the code already returns.
5059                b'r' if depth <= 0 && is_return_token(i) => return false,
5060                _ => {}
5061            },
5062            SingleQuote => {
5063                if c == b'\\' {
5064                    i += 1;
5065                } else if c == b'\'' {
5066                    state = Code;
5067                }
5068            }
5069            DoubleQuote => {
5070                if c == b'\\' {
5071                    i += 1;
5072                } else if c == b'"' {
5073                    state = Code;
5074                }
5075            }
5076            Template => {
5077                if c == b'\\' {
5078                    i += 1;
5079                } else if c == b'`' {
5080                    state = Code;
5081                }
5082            }
5083        }
5084        i += 1;
5085    }
5086
5087    true
5088}
5089
5090#[cfg(test)]
5091mod prop_tests {
5092    //! Property-based tests for the eval auto-return heuristic — the code that
5093    //! caused the worst bug in the system (silent corruption of multi-statement
5094    //! eval) and has bitten twice. These generate many JS-ish snippets and
5095    //! assert the invariants that keep eval correct.
5096    use super::should_prepend_return;
5097    use proptest::prelude::*;
5098
5099    /// A small set of non-keyword identifier-ish expressions.
5100    fn ident() -> impl Strategy<Value = String> {
5101        prop_oneof![
5102            Just("a".to_string()),
5103            Just("x".to_string()),
5104            Just("foo".to_string()),
5105            Just("window.x".to_string()),
5106            Just("document.title".to_string()),
5107            Just("obj.prop".to_string()),
5108            Just("arr[0]".to_string()),
5109            Just("localStorage".to_string()),
5110        ]
5111    }
5112
5113    /// A single bare expression: never starts with a statement keyword, has no
5114    /// top-level `;`, and contains no `return`.
5115    fn bare_expr() -> impl Strategy<Value = String> {
5116        prop_oneof![
5117            ident(),
5118            (ident(), ident()).prop_map(|(a, b)| format!("{a} + {b}")),
5119            (ident(), ident()).prop_map(|(a, b)| format!("{a}({b})")),
5120            ident().prop_map(|a| format!("{a}.length")),
5121            any::<u16>().prop_map(|n| n.to_string()),
5122        ]
5123    }
5124
5125    proptest! {
5126        /// Must never panic or hang on ANY input — including malformed code,
5127        /// unbalanced quotes, and arbitrary unicode (the scanner indexes bytes).
5128        #[test]
5129        fn never_panics_on_arbitrary_input(s in ".{0,256}") {
5130            let _ = should_prepend_return(&s);
5131        }
5132
5133        /// A single bare expression is safe to wrap with `return` → true.
5134        #[test]
5135        fn bare_expressions_are_prepended(e in bare_expr()) {
5136            prop_assert!(should_prepend_return(&e), "bare expr not prepended: {e:?}");
5137        }
5138
5139        /// THE critical bug class: `<expr>; return <expr>` must NOT be prepended
5140        /// (else `return <expr>;` runs and the rest is silently discarded).
5141        #[test]
5142        fn semicolon_multistatement_with_return_never_prepended(
5143            setup in bare_expr(), ret in bare_expr()
5144        ) {
5145            let code = format!("{setup}; return {ret}");
5146            prop_assert!(!should_prepend_return(&code), "would corrupt: {code:?}");
5147        }
5148
5149        /// Newline-separated (ASI) explicit return must also be left as-is.
5150        #[test]
5151        fn newline_explicit_return_never_prepended(pre in bare_expr(), ret in bare_expr()) {
5152            let code = format!("{pre}\nreturn {ret}");
5153            prop_assert!(!should_prepend_return(&code), "explicit return prepended: {code:?}");
5154        }
5155
5156        /// `;` or the word `return` INSIDE a string literal must not trigger a
5157        /// false multi-statement split — a bare string is one expression.
5158        #[test]
5159        fn semicolons_and_return_inside_strings_are_ignored(inner in "[a-z0-9;= ]{0,24}") {
5160            // `inner` never contains a quote, so the literal is well-formed.
5161            let code = format!("'do;not;split return {inner}'");
5162            prop_assert!(should_prepend_return(&code), "string literal mis-split: {code:?}");
5163        }
5164    }
5165}
5166
5167#[cfg(test)]
5168mod tests {
5169    use super::*;
5170
5171    #[cfg(feature = "sqlite")]
5172    #[test]
5173    fn database_path_resolution_rejects_lexical_escape() {
5174        let dir = tempfile::tempdir().unwrap();
5175        let root = dir.path().join("allowed");
5176        std::fs::create_dir(&root).unwrap();
5177        std::fs::File::create(dir.path().join("outside.db")).unwrap();
5178
5179        let err =
5180            VictauriMcpHandler::resolve_existing_db_path(&[root], "../outside.db").unwrap_err();
5181        assert!(err.contains("path traversal"), "unexpected error: {err}");
5182    }
5183
5184    #[cfg(feature = "sqlite")]
5185    #[test]
5186    fn database_path_resolution_accepts_contained_nested_file() {
5187        let dir = tempfile::tempdir().unwrap();
5188        let root = dir.path().join("allowed");
5189        let nested = root.join("nested");
5190        std::fs::create_dir_all(&nested).unwrap();
5191        let db = nested.join("app.db");
5192        std::fs::File::create(&db).unwrap();
5193
5194        let resolved =
5195            VictauriMcpHandler::resolve_existing_db_path(&[root], "nested/app.db").unwrap();
5196        // Resolution returns the CANONICAL validated path (opened == validated, closing the
5197        // lexical-vs-canonical TOCTOU), so compare against the canonical form of the target.
5198        assert_eq!(resolved, std::fs::canonicalize(&db).unwrap());
5199    }
5200
5201    #[cfg(all(feature = "sqlite", unix))]
5202    #[test]
5203    fn database_path_resolution_rejects_symlink_escape() {
5204        use std::os::unix::fs::symlink;
5205
5206        let dir = tempfile::tempdir().unwrap();
5207        let root = dir.path().join("allowed");
5208        std::fs::create_dir(&root).unwrap();
5209        let outside = dir.path().join("outside.db");
5210        std::fs::File::create(&outside).unwrap();
5211        symlink(&outside, root.join("linked.db")).unwrap();
5212
5213        let err = VictauriMcpHandler::resolve_existing_db_path(&[root], "linked.db").unwrap_err();
5214        assert!(err.contains("path traversal"), "unexpected error: {err}");
5215    }
5216
5217    #[cfg(feature = "sqlite")]
5218    #[test]
5219    fn sqlite_identifier_quoting_handles_hostile_table_names() {
5220        let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap();
5221        let conn = rusqlite::Connection::open(file.path()).unwrap();
5222        let name = "odd\"] table";
5223        let identifier = VictauriMcpHandler::quote_sqlite_identifier(name);
5224        conn.execute_batch(&format!(
5225            "CREATE TABLE {identifier} (id INTEGER); INSERT INTO {identifier} VALUES (1);"
5226        ))
5227        .unwrap();
5228        let count: i64 = conn
5229            .query_row(&format!("SELECT count(*) FROM {identifier}"), [], |row| {
5230                row.get(0)
5231            })
5232            .unwrap();
5233        assert_eq!(count, 1);
5234    }
5235
5236    #[test]
5237    fn env_filter_drops_secrets_keeps_safe() {
5238        // Safe, non-secret vars pass.
5239        assert!(is_safe_env_key("HOME"));
5240        assert!(is_safe_env_key("LANG"));
5241        assert!(is_safe_env_key("TAURI_ENV_PLATFORM"));
5242        assert!(is_safe_env_key("VICTAURI_PORT"));
5243        // Secret-looking vars are dropped even under a safe prefix (audit #5).
5244        assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY"));
5245        assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"));
5246        assert!(!is_safe_env_key("VICTAURI_AUTH_TOKEN"));
5247        assert!(!is_safe_env_key("VICTAURI_API_KEY"));
5248        // Unknown prefixes are dropped regardless.
5249        assert!(!is_safe_env_key("AWS_SECRET_ACCESS_KEY"));
5250        assert!(!is_safe_env_key("RANDOM_VAR"));
5251        // The broad TAURI_ namespace is no longer allowed — only TAURI_ENV_ — so
5252        // app-custom TAURI_ secrets are dropped even without a denylist hit.
5253        assert!(!is_safe_env_key("TAURI_CUSTOM_THING"));
5254        // Adversarial leaks closed (audit #5 follow-up): connection strings,
5255        // passphrases, PATs, JWTs, etc. under an allowed prefix.
5256        assert!(!is_safe_env_key("VICTAURI_DB_DSN"));
5257        assert!(!is_safe_env_key("VICTAURI_SIGNING_PASSPHRASE"));
5258        assert!(!is_safe_env_key("VICTAURI_GH_PAT"));
5259        assert!(!is_safe_env_key("VICTAURI_JWT"));
5260        assert!(!is_safe_env_key("VICTAURI_SESSION_ID"));
5261    }
5262
5263    #[test]
5264    fn prepend_return_bare_expressions() {
5265        assert!(should_prepend_return("document.title"));
5266        assert!(should_prepend_return("5 + 5"));
5267        assert!(should_prepend_return("\"justexpr\""));
5268        assert!(should_prepend_return("await fetch('/x')"));
5269        assert!(should_prepend_return(
5270            "document.querySelectorAll('a').length"
5271        ));
5272        assert!(should_prepend_return("x ? a : b"));
5273        // Single trailing semicolon on a bare expression is still an expression.
5274        assert!(should_prepend_return("document.title;"));
5275        // Semicolons inside strings must not be treated as boundaries.
5276        assert!(should_prepend_return("'a;b;c'"));
5277        assert!(should_prepend_return("\"x;y\".length"));
5278        // IIFE workaround: the `;` lives inside the arrow body (depth > 0).
5279        assert!(should_prepend_return("(()=>{window.x=5; return 'ok'})()"));
5280    }
5281
5282    #[test]
5283    fn no_prepend_for_statement_blocks() {
5284        // The original silent-corruption cases.
5285        assert!(!should_prepend_return(
5286            "localStorage.setItem('k','v'); return localStorage.getItem('k')"
5287        ));
5288        assert!(!should_prepend_return(
5289            "window.scrollTo(0,50); return window.scrollY"
5290        ));
5291        assert!(!should_prepend_return("console.log('x'); return 123"));
5292        assert!(!should_prepend_return("window.__z=7; return 'ok'"));
5293        // Explicit return without a preceding semicolon (newline-separated).
5294        assert!(!should_prepend_return("window.x = 5\nreturn window.x"));
5295    }
5296
5297    #[test]
5298    fn no_prepend_for_statement_keywords() {
5299        assert!(!should_prepend_return("return 42"));
5300        assert!(!should_prepend_return("const x = 1; return x"));
5301        assert!(!should_prepend_return("let y = 2"));
5302        assert!(!should_prepend_return("var z = 3"));
5303        assert!(!should_prepend_return("if (x) { return 1 }"));
5304        assert!(!should_prepend_return("for (const x of y) doThing(x)"));
5305        assert!(!should_prepend_return("throw new Error('x')"));
5306        assert!(!should_prepend_return("function f(){}"));
5307        assert!(!should_prepend_return("{ a: 1 }")); // object-literal-as-block ambiguity → as-is
5308    }
5309
5310    #[test]
5311    fn empty_code_no_prepend() {
5312        assert!(!should_prepend_return(""));
5313        assert!(!should_prepend_return("   "));
5314    }
5315
5316    #[test]
5317    fn envelope_unwrap_value() {
5318        assert_eq!(
5319            unwrap_eval_envelope(r#"{"__victauri_ok":"4DA","__victauri_type":"value"}"#.into()),
5320            Ok("\"4DA\"".to_string())
5321        );
5322        assert_eq!(
5323            unwrap_eval_envelope(r#"{"__victauri_ok":42,"__victauri_type":"value"}"#.into()),
5324            Ok("42".to_string())
5325        );
5326    }
5327
5328    #[test]
5329    fn envelope_unwrap_undefined_null() {
5330        assert_eq!(
5331            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#.into()),
5332            Ok("undefined".to_string())
5333        );
5334        assert_eq!(
5335            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"null"}"#.into()),
5336            Ok("null".to_string())
5337        );
5338    }
5339
5340    #[test]
5341    fn envelope_unwrap_error() {
5342        let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into());
5343        assert!(r.unwrap_err().contains("boom"));
5344    }
5345
5346    #[test]
5347    fn envelope_unwrap_deeply_nested_does_not_leak() {
5348        // Build an envelope whose value is nested far deeper than serde_json's
5349        // default recursion limit (128). The full parse fails, so the slice
5350        // fallback must return the value — NOT the raw `__victauri_ok` envelope.
5351        let mut value = String::from("0");
5352        for _ in 0..300 {
5353            value = format!("{{\"n\":{value}}}");
5354        }
5355        let raw = format!(r#"{{"__victauri_ok":{value},"__victauri_type":"value"}}"#);
5356        let out = unwrap_eval_envelope(raw).unwrap();
5357        assert!(
5358            out.starts_with(r#"{"n":"#),
5359            "deep value should be unwrapped, got: {}",
5360            &out[..out.len().min(40)]
5361        );
5362        assert!(
5363            !out.contains("__victauri_ok"),
5364            "envelope must not leak into the result"
5365        );
5366    }
5367
5368    #[test]
5369    fn js_string_simple() {
5370        assert_eq!(js_string("hello"), "\"hello\"");
5371    }
5372
5373    #[test]
5374    fn js_string_single_quotes() {
5375        let result = js_string("it's a test");
5376        assert!(result.contains("it's a test"));
5377    }
5378
5379    #[test]
5380    fn js_string_double_quotes() {
5381        let result = js_string(r#"say "hello""#);
5382        assert!(result.contains(r#"\""#));
5383    }
5384
5385    #[test]
5386    fn js_string_backslashes() {
5387        let result = js_string(r"path\to\file");
5388        assert!(result.contains(r"\\"));
5389    }
5390
5391    #[test]
5392    fn js_string_newlines_and_tabs() {
5393        let result = js_string("line1\nline2\ttab");
5394        assert!(result.contains(r"\n"));
5395        assert!(result.contains(r"\t"));
5396        assert!(!result.contains('\n'));
5397    }
5398
5399    #[test]
5400    fn js_string_null_bytes() {
5401        let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
5402        let result = js_string(&input);
5403        // serde_json escapes null bytes as
5404        assert!(result.contains("\\u0000"));
5405        assert!(!result.contains('\0'));
5406    }
5407
5408    #[test]
5409    fn js_string_template_literal_injection() {
5410        let result = js_string("`${alert(1)}`");
5411        // Should not contain unescaped backticks that could break template literals
5412        // serde_json wraps in double quotes, so backticks are safe
5413        assert!(result.starts_with('"'));
5414        assert!(result.ends_with('"'));
5415    }
5416
5417    #[test]
5418    fn js_string_unicode_separators() {
5419        // U+2028 (Line Separator) and U+2029 (Paragraph Separator) are valid in
5420        // JSON strings per RFC 8259, and serde_json passes them through literally.
5421        // Since js_string is used inside JS double-quoted strings (not template
5422        // literals), they are safe in modern JS engines (ES2019+).
5423        let result = js_string("a\u{2028}b\u{2029}c");
5424        // Verify the string is valid JSON that round-trips correctly
5425        let decoded: String = serde_json::from_str(&result).unwrap();
5426        assert_eq!(decoded, "a\u{2028}b\u{2029}c");
5427    }
5428
5429    #[test]
5430    fn js_string_empty() {
5431        assert_eq!(js_string(""), "\"\"");
5432    }
5433
5434    #[test]
5435    fn js_string_html_script_close() {
5436        // </script> in a JS string inside HTML could break out of script tags
5437        let result = js_string("</script><img onerror=alert(1)>");
5438        assert!(result.starts_with('"'));
5439        // The string is JSON-encoded; verify it round-trips safely
5440        let decoded: String = serde_json::from_str(&result).unwrap();
5441        assert_eq!(decoded, "</script><img onerror=alert(1)>");
5442    }
5443
5444    #[test]
5445    fn js_string_very_long() {
5446        let long = "a".repeat(100_000);
5447        let result = js_string(&long);
5448        assert!(result.len() >= 100_002); // quotes + content
5449    }
5450
5451    // ── URL validation tests ────────────────────────────────────────────────
5452
5453    #[test]
5454    fn url_allows_http() {
5455        assert!(validate_url("http://example.com", false).is_ok());
5456    }
5457
5458    #[test]
5459    fn url_allows_https() {
5460        assert!(validate_url("https://example.com/path?q=1", false).is_ok());
5461    }
5462
5463    #[test]
5464    fn url_allows_http_localhost() {
5465        assert!(validate_url("http://localhost:3000", false).is_ok());
5466    }
5467
5468    #[test]
5469    fn url_blocks_file_by_default() {
5470        let err = validate_url("file:///etc/passwd", false).unwrap_err();
5471        assert!(err.contains("file"), "error should mention the file scheme");
5472    }
5473
5474    #[test]
5475    fn url_allows_file_when_opted_in() {
5476        assert!(validate_url("file:///tmp/test.html", true).is_ok());
5477    }
5478
5479    #[test]
5480    fn url_blocks_javascript() {
5481        assert!(validate_url("javascript:alert(1)", false).is_err());
5482    }
5483
5484    #[test]
5485    fn url_blocks_javascript_case_insensitive() {
5486        assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
5487    }
5488
5489    #[test]
5490    fn url_blocks_data_scheme() {
5491        assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
5492    }
5493
5494    #[test]
5495    fn url_blocks_vbscript() {
5496        assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
5497    }
5498
5499    #[test]
5500    fn url_rejects_invalid() {
5501        assert!(validate_url("not a url at all", false).is_err());
5502    }
5503
5504    #[test]
5505    fn url_strips_control_chars() {
5506        // Control characters should be stripped, leaving a valid URL
5507        let input = format!("http://example{}com", '\0');
5508        assert!(validate_url(&input, false).is_ok());
5509    }
5510
5511    // ── CSS color sanitization tests ───────────────────────────────────────
5512
5513    #[test]
5514    fn css_color_valid_hex() {
5515        assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
5516        assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
5517        assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
5518    }
5519
5520    #[test]
5521    fn css_color_valid_rgb() {
5522        assert_eq!(
5523            sanitize_css_color("rgb(255, 0, 0)").unwrap(),
5524            "rgb(255, 0, 0)"
5525        );
5526        assert_eq!(
5527            sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
5528            "rgba(0, 0, 0, 0.5)"
5529        );
5530    }
5531
5532    #[test]
5533    fn css_color_valid_named() {
5534        assert_eq!(sanitize_css_color("red").unwrap(), "red");
5535        assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
5536    }
5537
5538    #[test]
5539    fn css_color_valid_hsl() {
5540        assert_eq!(
5541            sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
5542            "hsl(120, 50%, 50%)"
5543        );
5544    }
5545
5546    #[test]
5547    fn css_color_rejects_too_long() {
5548        let long = "a".repeat(101);
5549        assert!(sanitize_css_color(&long).is_err());
5550    }
5551
5552    #[test]
5553    fn css_color_rejects_backslash_escapes() {
5554        assert!(sanitize_css_color(r"red\00").is_err());
5555        assert!(sanitize_css_color(r"\72\65\64").is_err());
5556    }
5557
5558    #[test]
5559    fn css_color_rejects_url_injection() {
5560        assert!(sanitize_css_color("url(http://evil.com)").is_err());
5561        assert!(sanitize_css_color("URL(http://evil.com)").is_err());
5562    }
5563
5564    #[test]
5565    fn css_color_rejects_expression_injection() {
5566        assert!(sanitize_css_color("expression(alert(1))").is_err());
5567        assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
5568    }
5569
5570    #[test]
5571    fn css_color_rejects_import() {
5572        assert!(sanitize_css_color("@import url(evil.css)").is_err());
5573    }
5574
5575    #[test]
5576    fn css_color_rejects_semicolons_and_braces() {
5577        assert!(sanitize_css_color("red; background: url(evil)").is_err());
5578        assert!(sanitize_css_color("red} body { color: blue").is_err());
5579    }
5580
5581    #[test]
5582    fn css_color_rejects_special_chars() {
5583        assert!(sanitize_css_color("red<script>").is_err());
5584        assert!(sanitize_css_color("red\"onload=alert").is_err());
5585        assert!(sanitize_css_color("red'onclick=alert").is_err());
5586    }
5587
5588    #[test]
5589    fn css_color_trims_whitespace() {
5590        assert_eq!(sanitize_css_color("  red  ").unwrap(), "red");
5591    }
5592
5593    #[test]
5594    fn css_color_empty_string() {
5595        assert_eq!(sanitize_css_color("").unwrap(), "");
5596    }
5597}
5598
5599/// Dispatch-level authorization tests.
5600///
5601/// These exercise the REAL `execute_tool` dispatch path (not just the privacy
5602/// string matrix) to prove that blocked tools/actions actually return
5603/// `tool_disabled` and never reach their handler. This is the negative security
5604/// suite the audit required (Gate #5): the prior tests validated
5605/// `is_tool_enabled(...)` in isolation, which let structural dispatch bypasses
5606/// pass undetected.
5607#[cfg(test)]
5608mod authz_dispatch_tests {
5609    use super::*;
5610    use crate::bridge::WebviewBridge;
5611    use crate::privacy::PrivacyConfig;
5612    use std::collections::{HashMap, HashSet};
5613    use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
5614
5615    /// A bridge whose eval always fails immediately, so an *allowed* action that
5616    /// reaches the bridge returns a non-privacy error fast (no 30s hang), while a
5617    /// *blocked* action is rejected by dispatch before the bridge is ever touched.
5618    struct RejectingBridge;
5619
5620    impl WebviewBridge for RejectingBridge {
5621        fn eval_webview(&self, _label: Option<&str>, _script: &str) -> Result<(), String> {
5622            Err("eval rejected in authz dispatch test".to_string())
5623        }
5624        fn get_window_states(&self, _label: Option<&str>) -> Vec<WindowState> {
5625            Vec::new()
5626        }
5627        fn list_window_labels(&self) -> Vec<String> {
5628            Vec::new()
5629        }
5630        fn get_native_handle(&self, _label: Option<&str>) -> Result<isize, String> {
5631            Err("no handle".to_string())
5632        }
5633        fn manage_window(&self, _label: Option<&str>, _action: &str) -> Result<String, String> {
5634            Err("no window".to_string())
5635        }
5636        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
5637            Ok(())
5638        }
5639        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
5640            Ok(())
5641        }
5642        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
5643            Ok(())
5644        }
5645    }
5646
5647    fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
5648        Arc::new(VictauriState {
5649            event_log: EventLog::new(1000),
5650            registry: CommandRegistry::new(),
5651            port: std::sync::atomic::AtomicU16::new(0),
5652            pending_evals: Arc::new(Mutex::new(HashMap::new())),
5653            recorder: EventRecorder::new(1000),
5654            privacy,
5655            eval_timeout: std::time::Duration::from_millis(100),
5656            shutdown_tx: tokio::sync::watch::channel(false).0,
5657            started_at: std::time::Instant::now(),
5658            tool_invocations: std::sync::atomic::AtomicU64::new(0),
5659            allow_file_navigation: false,
5660            command_timings: crate::introspection::CommandTimings::new(),
5661            fault_registry: crate::introspection::FaultRegistry::new(),
5662            contract_store: crate::introspection::ContractStore::new(),
5663            startup_timeline: crate::introspection::StartupTimeline::new(),
5664            event_bus: crate::introspection::EventBusMonitor::default(),
5665            task_tracker: crate::introspection::TaskTracker::new(),
5666            bridge_ready: std::sync::atomic::AtomicBool::new(true),
5667            bridge_notify: tokio::sync::Notify::new(),
5668            db_search_paths: Vec::new(),
5669            screencast: Arc::new(crate::screencast::Screencast::default()),
5670            probes: crate::introspection::AppStateProbes::default(),
5671        })
5672    }
5673
5674    fn handler(privacy: PrivacyConfig) -> VictauriMcpHandler {
5675        VictauriMcpHandler::new(state_with(privacy), Arc::new(RejectingBridge))
5676    }
5677
5678    /// True iff the result is a privacy/authorization block (vs any other error).
5679    fn is_privacy_blocked(r: &CallToolResult) -> bool {
5680        r.is_error == Some(true)
5681            && r.content.iter().any(|c| {
5682                matches!(&c.raw, RawContent::Text(t)
5683                    if t.text.contains("disabled by privacy configuration"))
5684            })
5685    }
5686
5687    async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
5688        match h.execute_tool(tool, args).await {
5689            Ok(r) => r,
5690            Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
5691        }
5692    }
5693
5694    // ── Observe profile: every mutation/eval/compound-action must be blocked ──
5695
5696    #[tokio::test]
5697    async fn observe_blocks_mutations_and_eval_through_dispatch() {
5698        let h = handler(crate::privacy::observe_privacy_config());
5699        let blocked: &[(&str, serde_json::Value)] = &[
5700            ("eval_js", serde_json::json!({"code": "1"})),
5701            (
5702                "wait_for",
5703                serde_json::json!({"condition": "expression", "value": "true"}),
5704            ),
5705            ("screenshot", serde_json::json!({})),
5706            ("invoke_command", serde_json::json!({"command": "greet"})),
5707            ("verify_state", serde_json::json!({"frontend_expr": "1"})),
5708            (
5709                "assert_semantic",
5710                serde_json::json!({"expression": "1", "condition": "truthy"}),
5711            ),
5712            (
5713                "interact",
5714                serde_json::json!({"action": "click", "ref_id": "e1"}),
5715            ),
5716            (
5717                "input",
5718                serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
5719            ),
5720            (
5721                "storage",
5722                serde_json::json!({"action": "set", "key": "k", "value": "v"}),
5723            ),
5724            (
5725                "storage",
5726                serde_json::json!({"action": "delete", "key": "k"}),
5727            ),
5728            (
5729                "window",
5730                serde_json::json!({"action": "manage", "manage_action": "close"}),
5731            ),
5732            (
5733                "window",
5734                serde_json::json!({"action": "set_title", "title": "x"}),
5735            ),
5736            (
5737                "navigate",
5738                serde_json::json!({"action": "go_to", "url": "https://e.com"}),
5739            ),
5740            (
5741                "css",
5742                serde_json::json!({"action": "inject", "css": "body{}"}),
5743            ),
5744            ("route", serde_json::json!({"action": "clear_all"})),
5745            ("recording", serde_json::json!({"action": "start"})),
5746            ("recording", serde_json::json!({"action": "replay"})),
5747            ("logs", serde_json::json!({"action": "clear"})),
5748            (
5749                "fault",
5750                serde_json::json!({"action": "inject", "command": "x", "fault_type": "error"}),
5751            ),
5752            (
5753                "introspect",
5754                serde_json::json!({"action": "command_timings"}),
5755            ),
5756        ];
5757        for (tool, args) in blocked {
5758            let r = call(&h, tool, args.clone()).await;
5759            assert!(
5760                is_privacy_blocked(&r),
5761                "Observe must block {tool} {args} at dispatch, got: {:?}",
5762                r.content
5763            );
5764        }
5765    }
5766
5767    #[tokio::test]
5768    async fn observe_allows_read_only_through_dispatch() {
5769        let h = handler(crate::privacy::observe_privacy_config());
5770        // These reads must NOT be privacy-blocked (they may fail for other reasons
5771        // against the rejecting bridge, but never with a privacy block).
5772        let allowed: &[(&str, serde_json::Value)] = &[
5773            ("get_registry", serde_json::json!({})),
5774            ("get_memory_stats", serde_json::json!({})),
5775            ("window", serde_json::json!({"action": "list"})),
5776            ("logs", serde_json::json!({"action": "ipc"})),
5777            (
5778                "inspect",
5779                serde_json::json!({"action": "get_styles", "ref_id": "e1"}),
5780            ),
5781        ];
5782        for (tool, args) in allowed {
5783            let r = call(&h, tool, args.clone()).await;
5784            assert!(
5785                !is_privacy_blocked(&r),
5786                "Observe must allow {tool} {args} at dispatch (blocked unexpectedly)"
5787            );
5788        }
5789    }
5790
5791    // ── Test profile: interactions allowed, eval/replay/route blocked ─────────
5792
5793    #[tokio::test]
5794    async fn test_profile_dispatch_boundaries() {
5795        let h = handler(crate::privacy::test_privacy_config());
5796        // Allowed in Test:
5797        for (tool, args) in [
5798            (
5799                "interact",
5800                serde_json::json!({"action": "click", "ref_id": "e1"}),
5801            ),
5802            (
5803                "input",
5804                serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
5805            ),
5806            (
5807                "storage",
5808                serde_json::json!({"action": "set", "key": "k", "value": "v"}),
5809            ),
5810            ("navigate", serde_json::json!({"action": "go_back"})),
5811            ("recording", serde_json::json!({"action": "start"})),
5812            ("logs", serde_json::json!({"action": "clear"})),
5813        ] {
5814            let r = call(&h, tool, args.clone()).await;
5815            assert!(!is_privacy_blocked(&r), "Test must allow {tool} {args}");
5816        }
5817        // Blocked in Test (arbitrary eval, navigation mutation, replay, FullControl tools):
5818        for (tool, args) in [
5819            ("eval_js", serde_json::json!({"code": "1"})),
5820            (
5821                "wait_for",
5822                serde_json::json!({"condition": "expression", "value": "true"}),
5823            ),
5824            ("verify_state", serde_json::json!({"frontend_expr": "1"})),
5825            (
5826                "navigate",
5827                serde_json::json!({"action": "go_to", "url": "https://e.com"}),
5828            ),
5829            ("recording", serde_json::json!({"action": "replay"})),
5830            (
5831                "route",
5832                serde_json::json!({"action": "add", "pattern": "x"}),
5833            ),
5834            ("css", serde_json::json!({"action": "inject", "css": "x"})),
5835            (
5836                "window",
5837                serde_json::json!({"action": "set_title", "title": "x"}),
5838            ),
5839        ] {
5840            let r = call(&h, tool, args.clone()).await;
5841            assert!(is_privacy_blocked(&r), "Test must block {tool} {args}");
5842        }
5843    }
5844
5845    // ── disabled_tools: bare-name disable covers all of a compound tool's
5846    //    actions, and per-action disable is honored even when the handler
5847    //    historically did not check it (the route.clear bypass). ──────────────
5848
5849    #[tokio::test]
5850    async fn disabling_bare_compound_tool_blocks_all_actions() {
5851        let cfg = PrivacyConfig {
5852            disabled_tools: HashSet::from(["recording".to_string()]),
5853            ..Default::default()
5854        }; // FullControl with the whole `recording` tool disabled
5855        let h = handler(cfg);
5856        for action in ["start", "stop", "replay", "import", "export"] {
5857            let r = call(&h, "recording", serde_json::json!({"action": action})).await;
5858            assert!(
5859                is_privacy_blocked(&r),
5860                "disabling bare `recording` must block recording.{action}"
5861            );
5862        }
5863    }
5864
5865    #[tokio::test]
5866    async fn disabling_specific_action_is_honored_at_dispatch() {
5867        // The historical bypass: `route.clear`'s handler had no per-action check,
5868        // so a `disabled_tools` entry for it was silently ignored. The central
5869        // gate now enforces it.
5870        let cfg = PrivacyConfig {
5871            disabled_tools: HashSet::from([
5872                "route.clear".to_string(),
5873                "route.clear_all".to_string(),
5874            ]),
5875            ..Default::default()
5876        }; // FullControl: everything else allowed
5877        let h = handler(cfg);
5878
5879        let blocked = call(&h, "route", serde_json::json!({"action": "clear", "id": 1})).await;
5880        assert!(is_privacy_blocked(&blocked), "route.clear must be blocked");
5881        let blocked_all = call(&h, "route", serde_json::json!({"action": "clear_all"})).await;
5882        assert!(
5883            is_privacy_blocked(&blocked_all),
5884            "route.clear_all must be blocked"
5885        );
5886
5887        // A sibling action the operator did NOT disable is still reachable.
5888        let allowed = call(&h, "route", serde_json::json!({"action": "list"})).await;
5889        assert!(
5890            !is_privacy_blocked(&allowed),
5891            "route.list must remain allowed"
5892        );
5893    }
5894
5895    // Command-policy enforcement on invoke paths (A1/A2) and resource gating (B1)
5896    // are covered with side-effect detection (a bridge that records actual invokes)
5897    // in the `command_policy_dispatch_tests` module below — that proves the blocked
5898    // command never reaches the bridge, not merely that an error string is returned.
5899
5900    #[tokio::test]
5901    async fn full_control_allows_everything_at_dispatch() {
5902        let h = handler(PrivacyConfig::default());
5903        for (tool, args) in [
5904            ("recording", serde_json::json!({"action": "replay"})),
5905            ("route", serde_json::json!({"action": "clear_all"})),
5906            ("eval_js", serde_json::json!({"code": "1"})),
5907            ("fault", serde_json::json!({"action": "list"})),
5908        ] {
5909            let r = call(&h, tool, args.clone()).await;
5910            assert!(
5911                !is_privacy_blocked(&r),
5912                "FullControl must allow {tool} {args}"
5913            );
5914        }
5915    }
5916}
5917
5918/// Command-policy enforcement on EVERY command-invoking path (audit #30/#31, triage A1/A2).
5919///
5920/// The prior privacy suite validated the permission-string matrix — `is_tool_enabled("x")`
5921/// in isolation — which let structural dispatch bypasses pass undetected (the audit's
5922/// central criticism: "tests validate the STRING MATRIX, not actual dispatch behavior").
5923///
5924/// These tests instead drive the REAL dispatcher with a bridge that records every script
5925/// handed to `eval_webview`, and assert the dangerous **side effect** — the
5926/// `__TAURI_INTERNALS__.invoke(<command>)` script — is NEVER emitted when the command is on
5927/// the operator's blocklist, on each path that invokes commands OUTSIDE `invoke_command`:
5928/// `recording.replay`, `recording.import` + `replay`, `introspect.contract_record`, and
5929/// `introspect.contract_check`. Each has a positive control proving an *allowed* command IS
5930/// invoked (so a blanket-block can't make the negative test pass vacuously).
5931#[cfg(test)]
5932mod command_policy_dispatch_tests {
5933    use super::*;
5934    use crate::bridge::WebviewBridge;
5935    use crate::privacy::PrivacyConfig;
5936    use serde_json::json;
5937    use std::collections::{HashMap, HashSet};
5938    use std::sync::Mutex as StdMutex;
5939    use victauri_core::{
5940        AppEvent, CommandRegistry, EventLog, EventRecorder, IpcCall, IpcResult, RecordedEvent,
5941        RecordedSession, WindowState,
5942    };
5943
5944    /// A bridge that RECORDS every script passed to `eval_webview` (so a test can assert a
5945    /// blocklisted command's invoke was never emitted) then fails the eval fast — an allowed
5946    /// command is observably *attempted* without hanging on a callback that never arrives.
5947    ///
5948    /// When constructed via [`RecordingBridge::answering`] it also resolves the pre-eval
5949    /// liveness probe, simulating a healthy webview so an ALLOWED command's invoke actually
5950    /// reaches the bridge. Default-constructed bridges leave the probe unanswered — which is
5951    /// fine for negative tests, since a blocked command is rejected at the privacy gate
5952    /// *before* any eval (and thus never probes).
5953    #[derive(Clone, Default)]
5954    struct RecordingBridge {
5955        scripts: Arc<StdMutex<Vec<String>>>,
5956        pending_evals: Option<crate::PendingCallbacks>,
5957    }
5958
5959    /// Extract the 36-char eval id from a probe script of the form `…id:"<uuid>"…`.
5960    fn extract_probe_id(script: &str) -> Option<String> {
5961        let start = script.find("id:\"")? + 4;
5962        script.get(start..start + 36).map(str::to_string)
5963    }
5964
5965    impl RecordingBridge {
5966        /// A recording bridge that answers the liveness probe with the state's pending-evals
5967        /// map, so a permitted command's eval proceeds past the probe and is observably
5968        /// injected.
5969        fn answering(pending_evals: crate::PendingCallbacks) -> Self {
5970            Self {
5971                scripts: Arc::default(),
5972                pending_evals: Some(pending_evals),
5973            }
5974        }
5975
5976        /// True iff any recorded eval script invoked `command` via the Tauri IPC bridge.
5977        fn invoked(&self, command: &str) -> bool {
5978            let needle = format!("invoke({}", js_string(command));
5979            self.scripts
5980                .lock()
5981                .unwrap_or_else(std::sync::PoisonError::into_inner)
5982                .iter()
5983                .any(|s| s.contains(&needle))
5984        }
5985    }
5986
5987    impl WebviewBridge for RecordingBridge {
5988        fn eval_webview(&self, _label: Option<&str>, script: &str) -> Result<(), String> {
5989            self.scripts
5990                .lock()
5991                .unwrap_or_else(std::sync::PoisonError::into_inner)
5992                .push(script.to_string());
5993            // If wired with a pending-evals map, answer the pre-eval liveness probe
5994            // (simulating a healthy webview) so the real eval proceeds past it. The
5995            // real eval is still left unanswered, so it times out fast at the 100ms
5996            // test `eval_timeout` — we only care WHICH scripts reached the bridge,
5997            // never the eval's return value.
5998            if let Some(pending) = &self.pending_evals
5999                && script.contains("probe_ok")
6000                && let Some(id) = extract_probe_id(script)
6001            {
6002                let pending = pending.clone();
6003                std::thread::spawn(move || {
6004                    let mut map = pending.blocking_lock();
6005                    if let Some(tx) = map.remove(&id) {
6006                        let _ = tx.send("\"probe_ok\"".to_string());
6007                    }
6008                });
6009            }
6010            // Return Ok so `eval_with_return` injects BOTH its watchdog and the
6011            // user-code script (it bails on the first Err).
6012            Ok(())
6013        }
6014        fn get_window_states(&self, _l: Option<&str>) -> Vec<WindowState> {
6015            Vec::new()
6016        }
6017        fn list_window_labels(&self) -> Vec<String> {
6018            Vec::new()
6019        }
6020        fn get_native_handle(&self, _l: Option<&str>) -> Result<isize, String> {
6021            Err("no handle".to_string())
6022        }
6023        fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
6024            Err("no window".to_string())
6025        }
6026        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
6027            Ok(())
6028        }
6029        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
6030            Ok(())
6031        }
6032        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
6033            Ok(())
6034        }
6035    }
6036
6037    fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
6038        Arc::new(VictauriState {
6039            event_log: EventLog::new(1000),
6040            registry: CommandRegistry::new(),
6041            port: std::sync::atomic::AtomicU16::new(0),
6042            pending_evals: Arc::new(Mutex::new(HashMap::new())),
6043            recorder: EventRecorder::new(1000),
6044            privacy,
6045            eval_timeout: std::time::Duration::from_millis(100),
6046            shutdown_tx: tokio::sync::watch::channel(false).0,
6047            started_at: std::time::Instant::now(),
6048            tool_invocations: std::sync::atomic::AtomicU64::new(0),
6049            allow_file_navigation: false,
6050            command_timings: crate::introspection::CommandTimings::new(),
6051            fault_registry: crate::introspection::FaultRegistry::new(),
6052            contract_store: crate::introspection::ContractStore::new(),
6053            startup_timeline: crate::introspection::StartupTimeline::new(),
6054            event_bus: crate::introspection::EventBusMonitor::default(),
6055            task_tracker: crate::introspection::TaskTracker::new(),
6056            bridge_ready: std::sync::atomic::AtomicBool::new(true),
6057            bridge_notify: tokio::sync::Notify::new(),
6058            db_search_paths: Vec::new(),
6059            screencast: Arc::new(crate::screencast::Screencast::default()),
6060            probes: crate::introspection::AppStateProbes::default(),
6061        })
6062    }
6063
6064    // FullControl, except the named commands are blocklisted — exactly the scenario
6065    // the audit flagged: an operator who trusts `command_blocklist` to stop a
6066    // dangerous command.
6067    fn blocking(cmds: &[&str]) -> PrivacyConfig {
6068        PrivacyConfig {
6069            command_blocklist: cmds.iter().map(|s| (*s).to_string()).collect(),
6070            ..Default::default()
6071        }
6072    }
6073
6074    fn ipc_event(command: &str) -> AppEvent {
6075        AppEvent::Ipc(IpcCall {
6076            id: format!("c-{command}"),
6077            command: command.to_string(),
6078            timestamp: chrono::Utc::now(),
6079            duration_ms: Some(1),
6080            result: IpcResult::Ok(json!(true)),
6081            arg_size_bytes: 0,
6082            webview_label: "main".to_string(),
6083        })
6084    }
6085
6086    fn result_text(r: &CallToolResult) -> String {
6087        r.content
6088            .iter()
6089            .filter_map(|c| match &c.raw {
6090                RawContent::Text(t) => Some(t.text.clone()),
6091                _ => None,
6092            })
6093            .collect::<Vec<_>>()
6094            .join("\n")
6095    }
6096
6097    async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
6098        match h.execute_tool(tool, args).await {
6099            Ok(r) => r,
6100            Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
6101        }
6102    }
6103
6104    // ── introspect event_bus output cap (VIC-4) ──────────────────────────────
6105    #[tokio::test]
6106    async fn event_bus_caps_output_to_limit() {
6107        // The full buffers can be tens of thousands of events (megabytes); the action must cap
6108        // output (default 100, newest first) and still report the true total + a truncated flag.
6109        use crate::introspection::CapturedTauriEvent;
6110        let state = state_with(PrivacyConfig::default());
6111        for i in 0..150 {
6112            state.event_bus.push(CapturedTauriEvent {
6113                name: format!("evt-{i}"),
6114                payload: "{}".to_string(),
6115                timestamp: chrono::Utc::now().to_rfc3339(),
6116            });
6117        }
6118        let h = VictauriMcpHandler::new(state, Arc::new(RecordingBridge::default()));
6119
6120        // Default limit (100).
6121        let r = call(&h, "introspect", json!({"action": "event_bus"})).await;
6122        let v: serde_json::Value = serde_json::from_str(&result_text(&r)).unwrap();
6123        assert_eq!(
6124            v["tauri_events"]["count"], 150,
6125            "true total must be reported"
6126        );
6127        assert_eq!(v["tauri_events"]["returned"], 100, "default cap is 100");
6128        assert_eq!(v["tauri_events"]["truncated"], true);
6129        assert_eq!(v["tauri_events"]["events"].as_array().unwrap().len(), 100);
6130
6131        // Explicit smaller limit (passed via the generic `args` object).
6132        let r = call(
6133            &h,
6134            "introspect",
6135            json!({"action": "event_bus", "args": {"limit": 10}}),
6136        )
6137        .await;
6138        let v: serde_json::Value = serde_json::from_str(&result_text(&r)).unwrap();
6139        assert_eq!(v["tauri_events"]["returned"], 10);
6140        assert_eq!(v["tauri_events"]["events"].as_array().unwrap().len(), 10);
6141    }
6142
6143    // ── recording.replay (audit #30/#31, A1) ─────────────────────────────────
6144
6145    #[tokio::test]
6146    async fn replay_never_invokes_a_blocklisted_command() {
6147        let bridge = RecordingBridge::default();
6148        let state = state_with(blocking(&["delete_account"]));
6149        state.recorder.start("s1".to_string()).unwrap();
6150        state.recorder.record_event(ipc_event("delete_account"));
6151        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6152
6153        let r = call(&h, "recording", json!({"action": "replay"})).await;
6154
6155        assert!(
6156            !bridge.invoked("delete_account"),
6157            "SIDE-EFFECT LEAK: replay handed a blocklisted command's invoke to the bridge (audit #30/#31)"
6158        );
6159        assert!(
6160            result_text(&r).contains("blocked"),
6161            "replay should report the command as blocked, got: {}",
6162            result_text(&r)
6163        );
6164    }
6165
6166    #[tokio::test]
6167    async fn replay_does_invoke_an_allowed_command() {
6168        // Positive control: proves the negative test isn't vacuous (the path really
6169        // reaches the bridge for a permitted command).
6170        let state = state_with(PrivacyConfig::default());
6171        let bridge = RecordingBridge::answering(state.pending_evals.clone());
6172        state.recorder.start("s1".to_string()).unwrap();
6173        state.recorder.record_event(ipc_event("greet"));
6174        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6175
6176        let _ = call(&h, "recording", json!({"action": "replay"})).await;
6177
6178        assert!(
6179            bridge.invoked("greet"),
6180            "positive control failed: an ALLOWED command was not invoked, so the negative test proves nothing"
6181        );
6182    }
6183
6184    #[tokio::test]
6185    async fn imported_session_cannot_invoke_a_blocklisted_command() {
6186        // audit #31: a crafted session handed to an agent ("replay this to reproduce")
6187        // must not become arbitrary command invocation.
6188        let bridge = RecordingBridge::default();
6189        let state = state_with(blocking(&["wipe_database"]));
6190        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6191
6192        let session = RecordedSession {
6193            id: "poisoned".to_string(),
6194            started_at: chrono::Utc::now(),
6195            events: vec![RecordedEvent {
6196                index: 0,
6197                timestamp: chrono::Utc::now(),
6198                event: ipc_event("wipe_database"),
6199            }],
6200            checkpoints: Vec::new(),
6201        };
6202        let session_json = serde_json::to_string(&session).unwrap();
6203
6204        let imp = call(
6205            &h,
6206            "recording",
6207            json!({"action": "import", "session_json": session_json}),
6208        )
6209        .await;
6210        assert_ne!(
6211            imp.is_error,
6212            Some(true),
6213            "import itself should succeed: {}",
6214            result_text(&imp)
6215        );
6216
6217        let r = call(&h, "recording", json!({"action": "replay"})).await;
6218        assert!(
6219            !bridge.invoked("wipe_database"),
6220            "SIDE-EFFECT LEAK: an imported session replayed a blocklisted command (audit #31)"
6221        );
6222        assert!(result_text(&r).contains("blocked"));
6223    }
6224
6225    // ── introspect.contract_record / contract_check (audit #30, A2) ───────────
6226
6227    #[tokio::test]
6228    async fn contract_record_never_invokes_a_blocklisted_command() {
6229        let bridge = RecordingBridge::default();
6230        let state = state_with(blocking(&["delete_account"]));
6231        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6232
6233        let r = call(
6234            &h,
6235            "introspect",
6236            json!({"action": "contract_record", "command": "delete_account", "args": {"confirm": true}}),
6237        )
6238        .await;
6239
6240        assert!(
6241            !bridge.invoked("delete_account"),
6242            "SIDE-EFFECT LEAK: contract_record invoked a blocklisted command (audit #30)"
6243        );
6244        assert_eq!(r.is_error, Some(true));
6245        assert!(
6246            result_text(&r).contains("blocked by privacy configuration"),
6247            "got: {}",
6248            result_text(&r)
6249        );
6250    }
6251
6252    #[tokio::test]
6253    async fn contract_record_does_invoke_an_allowed_command() {
6254        let state = state_with(PrivacyConfig::default());
6255        let bridge = RecordingBridge::answering(state.pending_evals.clone());
6256        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6257
6258        let _ = call(
6259            &h,
6260            "introspect",
6261            json!({"action": "contract_record", "command": "get_settings"}),
6262        )
6263        .await;
6264
6265        assert!(
6266            bridge.invoked("get_settings"),
6267            "positive control failed: contract_record did not invoke an allowed command"
6268        );
6269    }
6270
6271    // ── pending-eval concurrency ceiling (audit: TOCTOU race) ────────────────
6272    #[tokio::test]
6273    async fn reserve_pending_is_a_hard_ceiling_under_concurrency() {
6274        // A check-then-insert (lock, read len(), unlock, …, lock, insert) races: many
6275        // concurrent callers all pass a STALE len() check before any inserts, blowing past
6276        // MAX_PENDING_EVALS. `reserve_pending` checks AND inserts under one lock, so the cap
6277        // is a true ceiling. Pre-fill to MAX-5, fire 50 concurrent reservations: EXACTLY 5
6278        // may succeed and the map must NEVER exceed the cap.
6279        let state = state_with(PrivacyConfig::default());
6280        {
6281            let mut p = state.pending_evals.lock().await;
6282            for i in 0..(MAX_PENDING_EVALS - 5) {
6283                let (tx, _rx) = tokio::sync::oneshot::channel();
6284                p.insert(format!("pre-{i}"), tx);
6285            }
6286        }
6287        let h = Arc::new(VictauriMcpHandler::new(
6288            state.clone(),
6289            Arc::new(RecordingBridge::default()),
6290        ));
6291        let mut tasks = Vec::new();
6292        for i in 0..50 {
6293            let h = h.clone();
6294            tasks.push(tokio::spawn(async move {
6295                let (tx, _rx) = tokio::sync::oneshot::channel();
6296                // keep rx alive until the reservation has been decided
6297                let ok = h.reserve_pending(&format!("c-{i}"), tx).await.is_ok();
6298                (ok, _rx)
6299            }));
6300        }
6301        let mut granted = 0;
6302        let mut keep = Vec::new();
6303        for t in tasks {
6304            let (ok, rx) = t.await.unwrap();
6305            if ok {
6306                granted += 1;
6307            }
6308            keep.push(rx); // hold receivers so reserved entries are not dropped/removed
6309        }
6310        let len = state.pending_evals.lock().await.len();
6311        assert!(
6312            len <= MAX_PENDING_EVALS,
6313            "ceiling breached: {len} > {MAX_PENDING_EVALS}"
6314        );
6315        assert_eq!(
6316            granted, 5,
6317            "exactly the 5 free slots should have been reserved, got {granted}"
6318        );
6319        drop(keep);
6320    }
6321
6322    #[tokio::test]
6323    async fn contract_check_never_reinvokes_a_now_blocklisted_command() {
6324        // A baseline recorded before the command was blocked must not be re-invoked
6325        // once the operator adds it to the blocklist (audit #30).
6326        let bridge = RecordingBridge::default();
6327        let state = state_with(blocking(&["delete_account"]));
6328        state
6329            .contract_store
6330            .record(crate::introspection::ContractBaseline {
6331                command: "delete_account".to_string(),
6332                args: json!({}),
6333                shape: crate::introspection::JsonShape::from_value(&json!(true)),
6334                sample: "true".to_string(),
6335                recorded_at: chrono_now(),
6336            });
6337        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6338
6339        let _ = call(&h, "introspect", json!({"action": "contract_check"})).await;
6340
6341        assert!(
6342            !bridge.invoked("delete_account"),
6343            "SIDE-EFFECT LEAK: contract_check re-invoked a now-blocklisted command (audit #30)"
6344        );
6345    }
6346
6347    // ── MCP resources honour the privacy gate (audit B1) ──────────────────────
6348
6349    #[test]
6350    fn resource_reads_are_gated_by_their_mirrored_capability() {
6351        // Resources bypass the tool dispatcher, so the read path must apply the same
6352        // gate. Disabling the capability a resource mirrors must block the resource.
6353        let cfg = PrivacyConfig {
6354            disabled_tools: HashSet::from([
6355                "logs.ipc".to_string(),
6356                "window.list".to_string(),
6357                "get_plugin_info".to_string(),
6358            ]),
6359            ..Default::default()
6360        };
6361        for uri in [
6362            RESOURCE_URI_IPC_LOG,
6363            RESOURCE_URI_WINDOWS,
6364            RESOURCE_URI_STATE,
6365        ] {
6366            let cap = resource_required_capability(uri).expect("resource maps to a capability");
6367            assert!(
6368                !cfg.is_tool_enabled(cap),
6369                "disabling capability {cap} must gate resource {uri} (audit B1)"
6370            );
6371        }
6372        // Sanity: with nothing disabled, all three resources read.
6373        let full = PrivacyConfig::default();
6374        for uri in [
6375            RESOURCE_URI_IPC_LOG,
6376            RESOURCE_URI_WINDOWS,
6377            RESOURCE_URI_STATE,
6378        ] {
6379            assert!(full.is_tool_enabled(resource_required_capability(uri).unwrap()));
6380        }
6381    }
6382
6383    // ── empty/whitespace auth token collapses to NO auth (audit B2) ───────────
6384
6385    #[tokio::test]
6386    async fn empty_auth_token_collapses_to_no_auth() {
6387        use http_body_util::BodyExt;
6388        use tower::ServiceExt;
6389
6390        for token in [Some(String::new()), Some("   ".to_string())] {
6391            let app = crate::mcp::server::build_app_full(
6392                state_with(PrivacyConfig::default()),
6393                Arc::new(RecordingBridge::default()),
6394                token.clone(),
6395                None,
6396            );
6397            let req = axum::extract::Request::builder()
6398                .uri("/info")
6399                .header("host", "127.0.0.1")
6400                .body(axum::body::Body::empty())
6401                .unwrap();
6402            let resp = app.oneshot(req).await.unwrap();
6403            assert_eq!(
6404                resp.status(),
6405                200,
6406                "/info must be reachable with empty token {token:?} (no auth layer)"
6407            );
6408            let bytes = resp.into_body().collect().await.unwrap().to_bytes();
6409            let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6410            assert_eq!(
6411                body["auth_required"],
6412                json!(false),
6413                "empty/whitespace token must report auth_required:false, not looks-protected-isnt (audit B2); token={token:?}"
6414            );
6415        }
6416    }
6417
6418    // ── app_info env allowlist drops secrets (audit #5/B3) ────────────────────
6419
6420    #[test]
6421    fn is_safe_env_key_drops_secrets_keeps_safe() {
6422        for secret in [
6423            "VICTAURI_AUTH_TOKEN",
6424            "TAURI_SIGNING_PRIVATE_KEY",
6425            "TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
6426            "CARGO_REGISTRY_TOKEN",
6427            "AWS_SECRET_ACCESS_KEY",
6428            "DATABASE_DSN",
6429            "GH_PAT",
6430        ] {
6431            assert!(
6432                !is_safe_env_key(secret),
6433                "{secret} is secret-shaped and must NOT be surfaced by app_info (audit #5)"
6434            );
6435        }
6436        for safe in [
6437            "HOME",
6438            "LANG",
6439            "TERM",
6440            "XDG_RUNTIME_DIR",
6441            "TAURI_ENV_PLATFORM",
6442        ] {
6443            assert!(
6444                is_safe_env_key(safe),
6445                "{safe} should be surfaced by app_info"
6446            );
6447        }
6448    }
6449}
6450
6451/// `screenshot` must refuse to "capture" a non-visible window.
6452///
6453/// Live-4DA dogfood (2026-06-16): requesting a hidden window (`label:"briefing"`)
6454/// returned a PNG that was actually the MAIN window's pixels — the OS capture path has
6455/// no live surface for an unmapped window, so it silently yields stale/foreign content.
6456/// The tool now checks visibility first and fails with an actionable message instead.
6457#[cfg(test)]
6458mod screenshot_visibility_tests {
6459    use super::*;
6460    use crate::bridge::WebviewBridge;
6461    use crate::privacy::PrivacyConfig;
6462    use std::collections::HashMap;
6463    use std::sync::Mutex as StdMutex;
6464    use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
6465
6466    fn window(label: &str, visible: bool) -> WindowState {
6467        WindowState {
6468            label: label.to_string(),
6469            title: label.to_string(),
6470            url: "http://localhost/".to_string(),
6471            visible,
6472            focused: false,
6473            maximized: false,
6474            minimized: false,
6475            fullscreen: false,
6476            position: (0, 0),
6477            size: (800, 600),
6478        }
6479    }
6480
6481    /// A bridge with a configurable window set that RECORDS the label `get_native_handle`
6482    /// is asked for, then errs. Recording the label lets a test assert WHICH window the
6483    /// screenshot tool resolved to (the audit-P2 case: omitted label must resolve to a
6484    /// VISIBLE window, never hidden "main"); the error lets a test prove the visibility gate
6485    /// fired *before* the OS-handle path was reached.
6486    struct ConfigBridge {
6487        windows: Vec<WindowState>,
6488        handle_label: Arc<StdMutex<Option<Option<String>>>>,
6489    }
6490
6491    impl ConfigBridge {
6492        fn new(windows: Vec<WindowState>) -> Self {
6493            Self {
6494                windows,
6495                handle_label: Arc::new(StdMutex::new(None)),
6496            }
6497        }
6498        /// The label `get_native_handle` was called with, if it was reached.
6499        /// `Some(Some(l))` = called with label `l`; `Some(None)` = called with the default;
6500        /// `None` = never reached (gate short-circuited).
6501        fn requested_handle(&self) -> Option<Option<String>> {
6502            self.handle_label
6503                .lock()
6504                .unwrap_or_else(std::sync::PoisonError::into_inner)
6505                .clone()
6506        }
6507    }
6508
6509    impl WebviewBridge for ConfigBridge {
6510        fn eval_webview(&self, _l: Option<&str>, _s: &str) -> Result<(), String> {
6511            Err("no eval".to_string())
6512        }
6513        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
6514            match label {
6515                Some(l) => self
6516                    .windows
6517                    .iter()
6518                    .filter(|w| w.label == l)
6519                    .cloned()
6520                    .collect(),
6521                None => self.windows.clone(),
6522            }
6523        }
6524        fn list_window_labels(&self) -> Vec<String> {
6525            self.windows.iter().map(|w| w.label.clone()).collect()
6526        }
6527        fn get_native_handle(&self, l: Option<&str>) -> Result<isize, String> {
6528            *self
6529                .handle_label
6530                .lock()
6531                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(l.map(str::to_string));
6532            Err("native handle path reached".to_string())
6533        }
6534        fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
6535            Ok(String::new())
6536        }
6537        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
6538            Ok(())
6539        }
6540        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
6541            Ok(())
6542        }
6543        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
6544            Ok(())
6545        }
6546    }
6547
6548    fn handler_with(bridge: Arc<ConfigBridge>) -> VictauriMcpHandler {
6549        let state = Arc::new(VictauriState {
6550            event_log: EventLog::new(100),
6551            registry: CommandRegistry::new(),
6552            port: std::sync::atomic::AtomicU16::new(0),
6553            pending_evals: Arc::new(Mutex::new(HashMap::new())),
6554            recorder: EventRecorder::new(100),
6555            privacy: PrivacyConfig::default(),
6556            eval_timeout: std::time::Duration::from_millis(100),
6557            shutdown_tx: tokio::sync::watch::channel(false).0,
6558            started_at: std::time::Instant::now(),
6559            tool_invocations: std::sync::atomic::AtomicU64::new(0),
6560            allow_file_navigation: false,
6561            command_timings: crate::introspection::CommandTimings::new(),
6562            fault_registry: crate::introspection::FaultRegistry::new(),
6563            contract_store: crate::introspection::ContractStore::new(),
6564            startup_timeline: crate::introspection::StartupTimeline::new(),
6565            event_bus: crate::introspection::EventBusMonitor::default(),
6566            task_tracker: crate::introspection::TaskTracker::new(),
6567            bridge_ready: std::sync::atomic::AtomicBool::new(true),
6568            bridge_notify: tokio::sync::Notify::new(),
6569            db_search_paths: Vec::new(),
6570            screencast: Arc::new(crate::screencast::Screencast::default()),
6571            probes: crate::introspection::AppStateProbes::default(),
6572        });
6573        VictauriMcpHandler::new(state, bridge)
6574    }
6575
6576    fn error_text(r: &CallToolResult) -> String {
6577        r.content
6578            .iter()
6579            .filter_map(|c| match &c.raw {
6580                RawContent::Text(t) => Some(t.text.clone()),
6581                _ => None,
6582            })
6583            .collect::<Vec<_>>()
6584            .join("\n")
6585    }
6586
6587    #[tokio::test]
6588    async fn hidden_window_screenshot_errors_clearly() {
6589        let bridge = Arc::new(ConfigBridge::new(vec![
6590            window("main", true),
6591            window("briefing", false),
6592        ]));
6593        let h = handler_with(bridge.clone());
6594        let r = h
6595            .screenshot(Parameters(ScreenshotParams {
6596                window_label: Some("briefing".to_string()),
6597            }))
6598            .await;
6599        assert_eq!(r.is_error, Some(true), "hidden window must error");
6600        let text = error_text(&r);
6601        assert!(
6602            text.contains("not visible"),
6603            "error must explain the window is not visible, got: {text}"
6604        );
6605        assert!(
6606            bridge.requested_handle().is_none(),
6607            "must short-circuit BEFORE the OS-handle/capture path"
6608        );
6609    }
6610
6611    #[tokio::test]
6612    async fn visible_window_screenshot_proceeds_to_capture() {
6613        let bridge = Arc::new(ConfigBridge::new(vec![
6614            window("main", true),
6615            window("briefing", false),
6616        ]));
6617        let h = handler_with(bridge.clone());
6618        let r = h
6619            .screenshot(Parameters(ScreenshotParams {
6620                window_label: Some("main".to_string()),
6621            }))
6622            .await;
6623        // The gate must let a visible window THROUGH to the OS-handle path (which this mock
6624        // fails) — proving the gate only blocks hidden windows.
6625        let text = error_text(&r);
6626        assert!(
6627            text.contains("native handle path reached")
6628                || text.contains("cannot get window handle"),
6629            "a visible window must reach the capture path, got: {text}"
6630        );
6631        assert_eq!(
6632            bridge.requested_handle(),
6633            Some(Some("main".to_string())),
6634            "must capture the explicitly requested visible window"
6635        );
6636    }
6637
6638    // GPT audit (P2): `screenshot {}` with NO label previously resolved through
6639    // find_window(None), which prefers "main" UNCONDITIONALLY — so an app that hides main but
6640    // keeps a secondary window visible captured hidden main (the wrong-pixels class the PR
6641    // exists to prevent). The tool must now resolve its own VISIBLE target.
6642    #[tokio::test]
6643    async fn omitted_label_skips_hidden_main_for_visible_secondary() {
6644        let bridge = Arc::new(ConfigBridge::new(vec![
6645            window("main", false),     // main hidden
6646            window("secondary", true), // a different window is visible
6647        ]));
6648        let h = handler_with(bridge.clone());
6649        let r = h
6650            .screenshot(Parameters(ScreenshotParams { window_label: None }))
6651            .await;
6652        let text = error_text(&r);
6653        assert!(
6654            !text.contains("not visible") && !text.contains("no visible window"),
6655            "a visible secondary window exists — must NOT error, got: {text}"
6656        );
6657        assert_eq!(
6658            bridge.requested_handle(),
6659            Some(Some("secondary".to_string())),
6660            "omitted label must resolve to the VISIBLE secondary, never hidden main"
6661        );
6662    }
6663
6664    // Omitted label with a visible main present must still prefer "main".
6665    #[tokio::test]
6666    async fn omitted_label_prefers_visible_main() {
6667        let bridge = Arc::new(ConfigBridge::new(vec![
6668            window("main", true),
6669            window("secondary", true),
6670        ]));
6671        let h = handler_with(bridge.clone());
6672        let _ = h
6673            .screenshot(Parameters(ScreenshotParams { window_label: None }))
6674            .await;
6675        assert_eq!(
6676            bridge.requested_handle(),
6677            Some(Some("main".to_string())),
6678            "with a visible main present, omitted label must resolve to main"
6679        );
6680    }
6681
6682    // Every window hidden + omitted label: error clearly, never capture a hidden window.
6683    #[tokio::test]
6684    async fn all_hidden_omitted_label_errors() {
6685        let bridge = Arc::new(ConfigBridge::new(vec![
6686            window("main", false),
6687            window("briefing", false),
6688        ]));
6689        let h = handler_with(bridge.clone());
6690        let r = h
6691            .screenshot(Parameters(ScreenshotParams { window_label: None }))
6692            .await;
6693        assert_eq!(r.is_error, Some(true), "all-hidden must error");
6694        assert!(
6695            error_text(&r).contains("no visible window"),
6696            "error must say there is no visible window, got: {}",
6697            error_text(&r)
6698        );
6699        assert!(
6700            bridge.requested_handle().is_none(),
6701            "must NOT reach the OS-handle path when every window is hidden"
6702        );
6703    }
6704
6705    // An unknown explicit label is passed THROUGH to get_native_handle (which produces the
6706    // canonical "window not found"), not rejected as "not visible".
6707    #[tokio::test]
6708    async fn unknown_label_falls_through_to_handle_resolution() {
6709        let bridge = Arc::new(ConfigBridge::new(vec![window("main", true)]));
6710        let h = handler_with(bridge.clone());
6711        let r = h
6712            .screenshot(Parameters(ScreenshotParams {
6713                window_label: Some("ghost".to_string()),
6714            }))
6715            .await;
6716        assert!(
6717            !error_text(&r).contains("not visible"),
6718            "unknown label must not be reported as 'not visible'"
6719        );
6720        assert_eq!(
6721            bridge.requested_handle(),
6722            Some(Some("ghost".to_string())),
6723            "unknown label must be forwarded verbatim to get_native_handle"
6724        );
6725    }
6726}