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
7pub(crate) mod authz;
8mod backend_params;
9mod bounded;
10mod compound_params;
11#[cfg(test)]
12mod drain_tests;
13mod hardening;
14mod helpers;
15mod introspection_params;
16mod other_params;
17pub(crate) mod page_json;
18mod rest;
19#[cfg(test)]
20mod robustness_tests;
21mod server;
22#[cfg(test)]
23mod tool_result_tests;
24mod verification_params;
25mod webview_params;
26mod window_params;
27
28use std::collections::{HashMap, HashSet};
29use std::sync::Arc;
30use std::sync::atomic::{AtomicBool, Ordering};
31
32use rmcp::handler::server::tool::ToolCallContext;
33use rmcp::handler::server::wrapper::Parameters;
34use rmcp::model::{
35    CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock,
36    InitializeResult, ListResourcesResult, ListToolsResult, PaginatedRequestParams,
37    ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, Resource,
38    ResourceContents, ServerCapabilities, SubscribeRequestParams, Tool, UnsubscribeRequestParams,
39};
40use rmcp::service::RequestContext;
41use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_router};
42use tokio::sync::Mutex;
43
44use crate::VictauriState;
45use crate::bridge::WebviewBridge;
46
47use helpers::{
48    RecoveryHint, build_ghost_report, ghost_ipc_outcomes_js, ghost_ipc_projection_js,
49    ipc_catalog_projection_js, ipc_timing_projection_js, ipc_timing_stats, js_string, json_result,
50    json_truthy, merge_command_catalog, missing_param, sanitize_css_color, sanitize_injected_css,
51    tool_disabled, tool_error, tool_error_with_hint, truncate_at_char_boundary, validate_url,
52};
53
54// MCP tool *parameter* types are an internal protocol surface: they are deserialized
55// from MCP/JSON, used only inside this crate's (private) tool methods, and change every
56// release as actions/fields are added. They are deliberately NOT part of the public API
57// (`pub(crate)`, not `pub use`), so adding a tool action or field is not a breaking change
58// and `cargo semver-checks` stays meaningful. Only `server::*` (build_app*,
59// VictauriMcpHandler) is the public MCP surface consumers actually use.
60pub(crate) use backend_params::*;
61pub(crate) use compound_params::*;
62/// Page-side probes run before trusted (OS-level) input. Internal: public only so the
63/// crate's own integration tests can run them in a JS engine. Not part of the supported API.
64#[doc(hidden)]
65pub use helpers::{trusted_click_probe_js, trusted_focus_probe_js};
66
67/// The IPC-log JS the tools send to the page. Internal plumbing, `pub` only so the jsdom suite
68/// (`tests/bridge_r5_tests.rs`) can run it against the real bridge — from a test binary of its
69/// own, since a seconds-long `node` child spawned from the library tests can inherit (and hold
70/// open) another test's server socket on Windows.
71#[doc(hidden)]
72pub mod ipc_log_js {
73    pub use super::helpers::{
74        ghost_ipc_outcomes_js, ipc_catalog_projection_js, ipc_timing_projection_js,
75    };
76    pub use super::{ipc_integrity_js, slow_ipc_js, trimmed_log_js};
77}
78pub(crate) use introspection_params::*;
79pub(crate) use other_params::{
80    AppStateParams, DiagnosticsParams, FindElementsParams, ResolveCommandParams,
81    SemanticAssertParams, WaitCondition, WaitForParams,
82};
83pub use server::*;
84pub(crate) use verification_params::*;
85pub(crate) use webview_params::*;
86pub(crate) use window_params::*;
87
88// ── MCP Handler ──────────────────────────────────────────────────────────────
89
90/// Maximum number of in-flight JavaScript eval requests. Prevents unbounded
91/// growth of the `pending_evals` map if callbacks are never resolved.
92pub(crate) const MAX_PENDING_EVALS: usize = 100;
93
94fn chrono_now() -> String {
95    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
96}
97
98/// Maximum length of JavaScript code accepted by the `eval_js` tool (1 MB).
99const MAX_EVAL_CODE_LEN: usize = 1_000_000;
100
101/// Maximum length of a JavaScript eval return value (5 MB).
102/// Results exceeding this are truncated to prevent memory exhaustion.
103const MAX_EVAL_RESULT_LEN: usize = 5_000_000;
104
105/// Default number of entries returned by IPC/network log tools when no explicit
106/// `limit` is given. Prevents busy apps (large logs) from exceeding the eval cap.
107const DEFAULT_LOG_LIMIT: usize = 100;
108
109/// Per-field byte cap applied to each IPC/network log entry before serialization.
110/// Large request/response bodies are truncated with a marker so the aggregate
111/// log stays well under [`MAX_EVAL_RESULT_LEN`] even on heavy-traffic apps.
112const MAX_LOG_FIELD_BYTES: usize = 4096;
113
114/// Hard cap on entries returned by `list_app_dir` (recursive). Without it a
115/// directory with millions of files (or a wide tree at max depth) would build an
116/// unbounded result Vec and blow the eval/output cap (audit B7). When hit, the
117/// listing stops and the response is marked `truncated: true`.
118const MAX_DIR_ENTRIES: usize = 10_000;
119/// Cap on directory entries `list_app_dir` EXAMINES (returned or not): with a `pattern` that
120/// matches nothing, [`MAX_DIR_ENTRIES`] never trips and the walk used to cover the whole tree.
121const MAX_DIR_VISITED: usize = 100_000;
122/// Wall-clock budget for one `list_app_dir` walk (slow or network filesystems).
123const MAX_DIR_WALK_TIME: std::time::Duration = std::time::Duration::from_secs(5);
124
125/// One bounded `list_app_dir` walk: stops (and reports `truncated`) at [`MAX_DIR_ENTRIES`]
126/// returned, [`MAX_DIR_VISITED`] examined, or [`MAX_DIR_WALK_TIME`].
127struct DirWalk {
128    /// The listing root, canonicalized ONCE (it used to be re-canonicalized per entry).
129    canon_base: std::path::PathBuf,
130    pattern: Option<String>,
131    max_depth: u32,
132    deadline: std::time::Instant,
133    max_visited: usize,
134    visited: usize,
135    truncated: bool,
136    entries: Vec<serde_json::Value>,
137}
138
139impl DirWalk {
140    fn out_of_budget(&mut self) -> bool {
141        if self.entries.len() >= MAX_DIR_ENTRIES
142            || self.visited >= self.max_visited
143            || std::time::Instant::now() >= self.deadline
144        {
145            self.truncated = true;
146        }
147        self.truncated
148    }
149
150    fn visit(&mut self, dir: &std::path::Path, base: &std::path::Path, depth: u32) {
151        let Ok(read_dir) = std::fs::read_dir(dir) else {
152            return;
153        };
154        for entry in read_dir.flatten() {
155            if self.out_of_budget() {
156                return;
157            }
158            self.visited += 1;
159            let path = entry.path();
160            if path.is_symlink() {
161                continue;
162            }
163            // `is_symlink` does not cover every redirecting filesystem object
164            // (notably Windows directory junctions/reparse points). Canonical
165            // containment is the actual boundary before metadata or recursion.
166            if !std::fs::canonicalize(&path).is_ok_and(|c| c.starts_with(&self.canon_base)) {
167                continue;
168            }
169            let name = entry.file_name().to_string_lossy().into_owned();
170            let is_dir = path.is_dir();
171            if let Some(pat) = self.pattern.as_deref()
172                && !is_dir
173                && !VictauriMcpHandler::matches_glob(&name, pat)
174            {
175                continue;
176            }
177            let relative = path
178                .strip_prefix(base)
179                .unwrap_or(&path)
180                .to_string_lossy()
181                .into_owned();
182            let meta = std::fs::metadata(&path).ok();
183
184            self.entries.push(serde_json::json!({
185                "name": name,
186                "path": relative,
187                "is_dir": is_dir,
188                "size": meta.as_ref().map(std::fs::Metadata::len),
189                "modified": meta.as_ref()
190                    .and_then(|m| m.modified().ok())
191                    .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
192                        .unwrap_or_default().as_secs()),
193            }));
194
195            if is_dir && depth < self.max_depth {
196                self.visit(&path, base, depth + 1);
197            }
198        }
199    }
200}
201
202/// `db_health` performs integrity checks and table counts against app-owned
203/// databases. Each size-dependent phase is bounded separately (see
204/// `database::db_health_report`) so a large or adversarial DB cannot hold a blocking
205/// worker indefinitely — and a slow phase degrades to a reported partial result instead
206/// of discarding the cheap ones.
207#[cfg(feature = "sqlite")]
208const DB_HEALTH_COUNT_BUDGET: std::time::Duration = std::time::Duration::from_secs(5);
209#[cfg(feature = "sqlite")]
210const DB_HEALTH_CHECK_BUDGET: std::time::Duration = std::time::Duration::from_secs(5);
211
212/// A timeout for an error message: whole seconds, or milliseconds when under a second (so a
213/// short per-call timeout never reads as "timed out after 0s").
214fn format_timeout(timeout: std::time::Duration) -> String {
215    if timeout < std::time::Duration::from_secs(1) {
216        format!("{}ms", timeout.as_millis())
217    } else {
218        format!("{}s", timeout.as_secs())
219    }
220}
221
222/// Error text for a window query the UI thread did not answer: a wedged/busy UI must never
223/// read as "no windows" or "window not found".
224fn ui_busy(error: &str) -> String {
225    format!("UI thread busy (dispatch timed out) - window state is unavailable, not empty: {error}")
226}
227
228/// The error result for a tool handler that panicked (see `bounded::CatchUnwind`).
229fn tool_panicked(tool: &str, panic: &str) -> CallToolResult {
230    tool_error(format!(
231        "internal error: the '{tool}' handler panicked ({panic}); the server is still running"
232    ))
233}
234
235/// Upper bound for `invoke_command`'s per-call `timeout_ms` (matches the eval-timeout ceiling).
236const MAX_INVOKE_TIMEOUT_MS: u64 = 300_000;
237
238/// How an eval call failed — typed, so a caller never infers from error text whether the code
239/// ran (e.g. whether the call is a command timing).
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241enum EvalFailureKind {
242    /// The code never ran: the call was refused or never delivered (saturated pending map, dead
243    /// bridge, failed injection, app exiting), or it did not parse.
244    NotSent,
245    /// Cut off in flight (timeout, app exit, window closed, page reload): it may or may not
246    /// have run.
247    Aborted,
248    /// The code ran in the page and threw, or ran but its result could not be returned.
249    Page,
250}
251
252/// A failed eval: what happened to the code, and the message for the agent.
253#[derive(Debug, Clone, PartialEq, Eq)]
254struct EvalFailure {
255    kind: EvalFailureKind,
256    message: String,
257}
258
259impl EvalFailure {
260    fn new(kind: EvalFailureKind, message: impl Into<String>) -> Self {
261        Self {
262            kind,
263            message: message.into(),
264        }
265    }
266
267    /// The code did not parse (reported by the bridge's post-wrapper check), so it never ran.
268    fn is_parse_error(&self) -> bool {
269        self.kind == EvalFailureKind::NotSent && self.message.starts_with(PARSE_ERROR_PREFIX)
270    }
271}
272
273/// How [`unwrap_eval_envelope`] reports code that did not parse.
274const PARSE_ERROR_PREFIX: &str = "JavaScript parse error:";
275
276/// How long `app_state` waits for an app-registered probe closure.
277///
278/// This used to be 1 s under `cfg(test)` for EVERY probe call, so a test of a panicking or a
279/// normal probe raced a deadline shorter than the scheduling tail of a loaded machine (a fresh
280/// blocking-pool thread plus the panic hook and unwind measured ~0.4 s at 2x CPU
281/// oversubscription, more in a full parallel suite) and flaked as "did not finish". Tests that
282/// exercise the deadline itself shorten [`VictauriMcpHandler::probe_timeout`] on their handler.
283const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
284
285/// App-registered probes allowed to run at once. A probe that hangs keeps its blocking thread
286/// past [`PROBE_TIMEOUT`]; the cap stops repeated calls to it from leaking a thread each.
287pub(crate) const MAX_CONCURRENT_PROBES: usize = 4;
288
289/// `read_app_file` reads allowed to run at once (a read blocked on a FIFO or slow device keeps
290/// its thread past [`READ_APP_FILE_TIMEOUT`]).
291pub(crate) const MAX_CONCURRENT_FILE_READS: usize = 4;
292
293/// How long `read_app_file` waits for its (bounded, at most 10 MB) read. Tests that exercise
294/// the deadline shorten [`VictauriMcpHandler::file_read_timeout`] (see [`PROBE_TIMEOUT`]).
295const READ_APP_FILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
296
297/// Distinct command names `CommandTimings` tracks (its private `MAX_TIMED_COMMANDS`, mirrored
298/// here — a test pins the two together). Once that many are tracked, new names are dropped and
299/// `introspect command_timings` reports `saturated: true`.
300pub(crate) const COMMAND_TIMINGS_CAP: usize = 1024;
301
302/// Upper bound for an injected `fault` delay (matches the `wait_for` ceiling).
303const MAX_FAULT_DELAY_MS: u64 = 120_000;
304
305/// Pixels between (and around) the cells of an `animation scrub` filmstrip.
306const FILMSTRIP_GAP: u32 = 4;
307
308/// Total time `recording stop` spends on its final flush of every window.
309const FINAL_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
310/// How often a slow eval re-checks that its target window still exists (first check after
311/// one interval, so fast evals never pay for it).
312const EVAL_WINDOW_WATCH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
313/// An abandoned `trace` auto-stops after this long (it captures a window every interval).
314const MAX_TRACE_DURATION: std::time::Duration = std::time::Duration::from_secs(30 * 60);
315/// Cap on the base64 bytes returned by one `trace frames` call.
316const MAX_TRACE_FRAMES_RESPONSE_BYTES: usize = 25 * 1024 * 1024;
317
318const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
319const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
320const RESOURCE_URI_STATE: &str = "victauri://state";
321
322/// SEP-2549 freshness hint for `tools/list` / `resources/list` results (5 minutes).
323/// Both lists are fixed for the process lifetime, so clients may cache them; a
324/// `notifications/tools/list_changed` (emitted by the CLI bridge on backend swap)
325/// still invalidates earlier. Conservative rather than "forever" so a client that
326/// only honors TTLs re-syncs within minutes of an app rebuild on the same port.
327const LIST_RESULT_TTL_MS: u64 = 300_000;
328
329/// Map an MCP resource URI to the tool call it mirrors: `(bare tool, capability)`.
330/// Resources are served outside the tool dispatcher, so `read_resource`/`subscribe` apply
331/// the same gate as a call of that tool action (audit B1) — including a disable of the
332/// whole tool by its bare name (R4-NET3). Returns `None` for an unknown URI (handled as
333/// not-found downstream).
334fn resource_required_capability(uri: &str) -> Option<(&'static str, &'static str)> {
335    match uri {
336        // Reading the IPC log via a resource == the `logs ipc` tool action.
337        RESOURCE_URI_IPC_LOG => Some(("logs", "logs.ipc")),
338        // Window states == the `window list` action.
339        RESOURCE_URI_WINDOWS => Some(("window", "window.list")),
340        // The state summary == reading plugin info.
341        RESOURCE_URI_STATE => Some(("get_plugin_info", "get_plugin_info")),
342        _ => None,
343    }
344}
345
346/// Whether the privacy configuration permits reading (or subscribing to) resource `uri`:
347/// exactly when it permits the tool call the resource mirrors. An unknown URI is not gated
348/// here (it is reported as not found).
349fn resource_allowed(privacy: &crate::privacy::PrivacyConfig, uri: &str) -> bool {
350    resource_required_capability(uri)
351        .is_none_or(|(tool, capability)| privacy.is_call_allowed(tool, capability))
352}
353
354const BRIDGE_VERSION: &str = env!("CARGO_PKG_VERSION");
355
356const SAFE_ENV_PREFIXES: &[&str] = &[
357    "HOME",
358    "USER",
359    "LANG",
360    "LC_",
361    "TERM",
362    "SHELL",
363    "DISPLAY",
364    "XDG_",
365    // Only Tauri's build-env namespace, NOT all of TAURI_ — the latter is an
366    // app-custom namespace that can hold secrets (audit #5).
367    "TAURI_ENV_",
368    "VICTAURI_",
369    "NODE_ENV",
370    "OS",
371    "HOSTNAME",
372    "PWD",
373    "SHLVL",
374    "LOGNAME",
375];
376
377/// Substrings that mark an env var as a secret. Even when a name matches a
378/// `SAFE_ENV_PREFIXES` entry it is dropped if it contains one of these — a prefix
379/// like `TAURI_`/`VICTAURI_` otherwise leaks `TAURI_SIGNING_PRIVATE_KEY`,
380/// `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, or `VICTAURI_AUTH_TOKEN` (audit #5).
381const SECRET_ENV_SUBSTRINGS: &[&str] = &[
382    "TOKEN",
383    "SECRET",
384    "PASS", // PASSWORD, PASSWD, PASSPHRASE
385    "PRIVATE",
386    "CREDENTIAL",
387    "APIKEY",
388    "AUTH",
389    "_KEY",
390    "DSN", // connection strings with embedded creds
391    "PAT", // personal access token
392    "JWT",
393    "BEARER",
394    "SESSION",
395    "COOKIE",
396    "SALT",
397    "CERT",
398    "SIGN", // signing keys/material
399    "LICENSE",
400];
401
402/// Whether an env var name is safe to surface via `app_info`: it must match a
403/// known-safe prefix AND not look like a secret (audit #5).
404fn is_safe_env_key(key: &str) -> bool {
405    let upper = key.to_uppercase();
406    SAFE_ENV_PREFIXES
407        .iter()
408        .any(|prefix| upper.starts_with(prefix))
409        && !SECRET_ENV_SUBSTRINGS.iter().any(|s| upper.contains(s))
410}
411
412/// MCP tool handler that dispatches tool calls to the webview bridge and state.
413#[derive(Clone)]
414pub struct VictauriMcpHandler {
415    state: Arc<VictauriState>,
416    bridge: Arc<dyn WebviewBridge>,
417    subscriptions: Arc<Mutex<HashSet<String>>>,
418    bridge_checked: Arc<AtomicBool>,
419    /// Window keys whose previous eval timed out. Retained only to annotate the
420    /// error on the *next* eval (the bridge is probed before every eval anyway).
421    timed_out_labels: Arc<Mutex<HashSet<String>>>,
422    /// Slots for running app probes ([`MAX_CONCURRENT_PROBES`]); held by the probe's thread.
423    probe_slots: Arc<tokio::sync::Semaphore>,
424    /// Slots for `read_app_file` reads ([`MAX_CONCURRENT_FILE_READS`]); held by the read's thread.
425    file_slots: Arc<tokio::sync::Semaphore>,
426    /// Deadline for one app probe ([`PROBE_TIMEOUT`]; tests of the deadline shorten it).
427    probe_timeout: std::time::Duration,
428    /// Deadline for one `read_app_file` read ([`READ_APP_FILE_TIMEOUT`]; likewise).
429    file_read_timeout: std::time::Duration,
430}
431
432#[tool_router]
433impl VictauriMcpHandler {
434    // ── Standalone Tools ────────────────────────────────────────────────────
435
436    #[tool(
437        description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.",
438        annotations(
439            read_only_hint = false,
440            destructive_hint = true,
441            idempotent_hint = false,
442            open_world_hint = false
443        )
444    )]
445    async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
446        if !self.state.privacy.is_tool_enabled("eval_js") {
447            return tool_disabled("eval_js");
448        }
449        if params.code.len() > MAX_EVAL_CODE_LEN {
450            return tool_error("code exceeds maximum length (1 MB)");
451        }
452        match self
453            .eval_with_return(&params.code, params.webview_label.as_deref())
454            .await
455        {
456            Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
457            Err(e) => tool_error(e),
458        }
459    }
460
461    #[tool(
462        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).",
463        annotations(
464            read_only_hint = true,
465            destructive_hint = false,
466            idempotent_hint = true,
467            open_world_hint = false
468        )
469    )]
470    async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
471        let format = params.format.unwrap_or(SnapshotFormat::Compact);
472        let format_str = match format {
473            SnapshotFormat::Compact => "compact",
474            SnapshotFormat::Json => "json",
475        };
476        let code = format!(
477            "return window.__VICTAURI__?.snapshot({})",
478            js_string(format_str)
479        );
480        self.eval_bridge(&code, params.webview_label.as_deref())
481            .await
482    }
483
484    #[tool(
485        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.",
486        annotations(
487            read_only_hint = true,
488            destructive_hint = false,
489            idempotent_hint = true,
490            open_world_hint = false
491        )
492    )]
493    async fn find_elements(
494        &self,
495        Parameters(params): Parameters<FindElementsParams>,
496    ) -> CallToolResult {
497        let mut parts: Vec<String> = Vec::new();
498        if let Some(t) = &params.text {
499            parts.push(format!("text: {}", js_string(t)));
500        }
501        if let Some(r) = &params.role {
502            parts.push(format!("role: {}", js_string(r)));
503        }
504        if let Some(tid) = &params.test_id {
505            parts.push(format!("test_id: {}", js_string(tid)));
506        }
507        if let Some(c) = params.css.as_ref().or(params.selector.as_ref()) {
508            parts.push(format!("css: {}", js_string(c)));
509        }
510        if let Some(n) = &params.name {
511            parts.push(format!("name: {}", js_string(n)));
512        }
513        if let Some(max) = params.max_results {
514            parts.push(format!("max_results: {max}"));
515        }
516        if let Some(t) = &params.tag {
517            parts.push(format!("tag: {}", js_string(t)));
518        }
519        if let Some(p) = &params.placeholder {
520            parts.push(format!("placeholder: {}", js_string(p)));
521        }
522        if let Some(a) = &params.alt {
523            parts.push(format!("alt: {}", js_string(a)));
524        }
525        if let Some(ta) = &params.title_attr {
526            parts.push(format!("title_attr: {}", js_string(ta)));
527        }
528        if let Some(l) = &params.label {
529            parts.push(format!("label: {}", js_string(l)));
530        }
531        if let Some(true) = params.exact {
532            parts.push("exact: true".to_string());
533        }
534        if let Some(e) = params.enabled {
535            parts.push(format!("enabled: {e}"));
536        }
537        let code = format!(
538            "return window.__VICTAURI__?.findElements({{ {} }})",
539            parts.join(", ")
540        );
541        match self
542            .eval_with_return(&code, params.webview_label.as_deref())
543            .await
544        {
545            Ok(result) => {
546                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
547                    && let Some(err) = parsed.get("error").and_then(|e| e.as_str())
548                {
549                    return tool_error(err);
550                }
551                CallToolResult::success(vec![ContentBlock::text(result)])
552            }
553            Err(e) => tool_error(e),
554        }
555    }
556
557    #[tool(
558        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. Waits up to the eval timeout (30s) — pass `timeout_ms` (max 300000) for a legitimately slow command. Subject to privacy command filtering.",
559        annotations(
560            read_only_hint = false,
561            destructive_hint = true,
562            idempotent_hint = false,
563            open_world_hint = false
564        )
565    )]
566    async fn invoke_command(
567        &self,
568        Parameters(params): Parameters<InvokeCommandParams>,
569    ) -> CallToolResult {
570        if !self.state.privacy.is_invoke_allowed(&params.command) {
571            return tool_disabled("invoke_command");
572        }
573        if !self.state.privacy.is_command_allowed(&params.command) {
574            return tool_error(format!(
575                "command '{}' is blocked by privacy configuration",
576                params.command
577            ));
578        }
579
580        // ── Fault injection check ──
581        if let Some(fault) = self.state.fault_registry.check_and_trigger(&params.command) {
582            match fault {
583                crate::introspection::FaultType::Delay { delay_ms } => {
584                    tracing::info!(
585                        command = %params.command,
586                        delay_ms = delay_ms,
587                        "fault injection: delaying command"
588                    );
589                    tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
590                    // After delay, continue with normal execution below
591                }
592                crate::introspection::FaultType::Error { ref message } => {
593                    tracing::info!(
594                        command = %params.command,
595                        "fault injection: returning error"
596                    );
597                    return tool_error(format!(
598                        "[FAULT INJECTED] command '{}': {message}",
599                        params.command
600                    ));
601                }
602                crate::introspection::FaultType::Drop => {
603                    tracing::info!(
604                        command = %params.command,
605                        "fault injection: dropping response"
606                    );
607                    return CallToolResult::success(vec![ContentBlock::text("{}")]);
608                }
609                crate::introspection::FaultType::Corrupt => {
610                    tracing::info!(
611                        command = %params.command,
612                        "fault injection: corrupting response"
613                    );
614                    // Execute normally but mangle the response
615                    let args_json = params.args.unwrap_or(serde_json::json!({}));
616                    let args_str =
617                        serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
618                    let code = format!(
619                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
620                        js_string(&params.command)
621                    );
622                    if let Ok(result) = self
623                        .eval_with_return(&code, params.webview_label.as_deref())
624                        .await
625                    {
626                        let corrupted = format!(
627                            "{{\"__corrupted\":true,\"original_length\":{},\"fault\":\"corrupt\"}}",
628                            result.len()
629                        );
630                        return CallToolResult::success(vec![ContentBlock::text(corrupted)]);
631                    }
632                    return CallToolResult::success(vec![ContentBlock::text(
633                        "{\"__corrupted\":true,\"fault\":\"corrupt\",\"note\":\"original invocation also failed\"}",
634                    )]);
635                }
636            }
637        }
638
639        // ── Normal execution with timing ──
640        let start = std::time::Instant::now();
641        let args_json = params.args.unwrap_or(serde_json::json!({}));
642        let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
643        let code = format!(
644            "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
645            js_string(&params.command)
646        );
647        let timeout = params.timeout_ms.map_or(self.state.eval_timeout, |ms| {
648            std::time::Duration::from_millis(ms.clamp(1, MAX_INVOKE_TIMEOUT_MS))
649        });
650        let result = self
651            .eval_outcome(&code, params.webview_label.as_deref(), timeout)
652            .await;
653        let elapsed = start.elapsed();
654        // Only calls that reached the command are timings: one never sent (saturated, dead
655        // bridge, failed injection) measures nothing, and one cut off (timeout, app exit, closed
656        // window, reload) measures how long we waited — both skewed p95.
657        let ran = result
658            .as_ref()
659            .map_or_else(|f| f.kind == EvalFailureKind::Page, |_| true);
660        if ran {
661            self.state.command_timings.record(&params.command, elapsed);
662        }
663        let result = result.map_err(|f| f.message);
664
665        match result {
666            Ok(result) => {
667                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
668                    && let Some(err) = parsed.get("__error").and_then(|e| e.as_str())
669                {
670                    return tool_error(format!(
671                        "command '{}' returned error: {err}",
672                        params.command
673                    ));
674                }
675                CallToolResult::success(vec![ContentBlock::text(result)])
676            }
677            Err(e) if e.starts_with("eval timed out") => tool_error(format!(
678                "invoke_command failed: {e} If the command is legitimately slow, pass \
679                 `timeout_ms` (up to {MAX_INVOKE_TIMEOUT_MS})."
680            )),
681            Err(e) => tool_error(format!("invoke_command failed: {e}")),
682        }
683    }
684
685    #[tool(
686        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.",
687        annotations(
688            read_only_hint = true,
689            destructive_hint = false,
690            idempotent_hint = true,
691            open_world_hint = false
692        )
693    )]
694    async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
695        if !self.state.privacy.is_tool_enabled("screenshot") {
696            return tool_disabled("screenshot");
697        }
698        // Resolve the EXACT visible window BEFORE touching the OS handle — a silent
699        // wrong-window image is worse than a clear failure (see the helper for the history).
700        let target_label = match self.resolve_visible_capture_target(params.window_label.as_deref())
701        {
702            Ok(label) => label,
703            Err(e) => return tool_error(e),
704        };
705        match self.bridge.get_native_handle(Some(&target_label)) {
706            Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
707                Ok(png_bytes) => {
708                    use base64::Engine;
709                    let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
710                    CallToolResult::success(vec![ContentBlock::image(b64, "image/png")])
711                }
712                Err(e) => tool_error(format!("screenshot capture failed: {e}")),
713            },
714            Err(e) => tool_error(format!("cannot get window handle: {e}")),
715        }
716    }
717
718    #[tool(
719        description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.",
720        annotations(
721            read_only_hint = false,
722            destructive_hint = true,
723            idempotent_hint = false,
724            open_world_hint = false
725        )
726    )]
727    async fn verify_state(
728        &self,
729        Parameters(params): Parameters<VerifyStateParams>,
730    ) -> CallToolResult {
731        if !self.state.privacy.is_tool_enabled("eval_js") {
732            return tool_disabled("verify_state requires eval_js capability");
733        }
734        let code = expression_eval_code(&params.frontend_expr);
735        let frontend_json = match self
736            .eval_with_return(&code, params.webview_label.as_deref())
737            .await
738        {
739            Ok(result) => result,
740            Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
741        };
742
743        let frontend_state: serde_json::Value = match parse_expression_value(&frontend_json) {
744            Ok(v) => v,
745            Err(e) => {
746                return tool_error(format!(
747                    "frontend expression did not return valid JSON: {e}"
748                ));
749            }
750        };
751
752        let backend_state = if let Some(state) = params.backend_state {
753            state
754        } else if let Some(ref cmd) = params.backend_command {
755            // Gate on BOTH is_invoke_allowed and is_command_allowed, matching
756            // invoke_command and the contract/replay paths — backend_command
757            // previously checked only the blocklist (audit #30 follow-up).
758            if !self.state.privacy.is_invoke_allowed(cmd)
759                || !self.state.privacy.is_command_allowed(cmd)
760            {
761                return tool_error(format!(
762                    "command '{cmd}' is blocked by privacy configuration"
763                ));
764            }
765            let args = params.backend_args.unwrap_or(serde_json::json!({}));
766            let args_str = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
767            let invoke_code = format!(
768                "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
769                js_string(cmd)
770            );
771            match self
772                .eval_with_return(&invoke_code, params.webview_label.as_deref())
773                .await
774            {
775                Ok(result) => match serde_json::from_str(&result) {
776                    Ok(v) => v,
777                    Err(e) => {
778                        return tool_error(format!(
779                            "backend command '{cmd}' did not return valid JSON: {e}"
780                        ));
781                    }
782                },
783                Err(e) => {
784                    return tool_error(format!("failed to invoke backend command '{cmd}': {e}"));
785                }
786            }
787        } else {
788            return tool_error("either backend_state or backend_command must be provided");
789        };
790
791        let result = victauri_core::verify_state(frontend_state, backend_state);
792        json_result(&result)
793    }
794
795    #[tool(
796        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. `ipc_capture` reports how complete the underlying IPC picture is (a `capture` other than `full` means calls may be missing).",
797        annotations(
798            read_only_hint = true,
799            destructive_hint = false,
800            idempotent_hint = true,
801            open_world_hint = false
802        )
803    )]
804    async fn detect_ghost_commands(
805        &self,
806        Parameters(params): Parameters<GhostCommandParams>,
807    ) -> CallToolResult {
808        // Project a per-command OUTCOME summary in JS ({command, ok, err}, deduped). Ghost
809        // detection is outcome-based (VIC-1): a command that returned success provably has a
810        // handler and is never a ghost; one that errored "not found" is a confirmed ghost.
811        // Aggregating per command keeps this tiny even on a busy app (avoids the eval cap).
812        // When `since_ms` is set, the projection time-windows to the current test's traffic.
813        let code = ghost_ipc_outcomes_js(params.since_ms);
814        let ipc_json = match self
815            .eval_with_return(&code, params.webview_label.as_deref())
816            .await
817        {
818            Ok(r) => r,
819            Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
820        };
821
822        let outcomes: Vec<crate::mcp::helpers::IpcOutcome> = match serde_json::from_str(&ipc_json) {
823            Ok(v) => v,
824            Err(e) => return tool_error(format!("failed to parse IPC log JSON: {e}")),
825        };
826
827        let mut report = build_ghost_report(&outcomes, &self.state.registry);
828        if let Some(obj) = report.as_object_mut() {
829            obj.insert(
830                "ipc_capture".to_string(),
831                self.ipc_capture_status(params.webview_label.as_deref())
832                    .await,
833            );
834        }
835        json_result(&report)
836    }
837
838    #[tool(
839        description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.",
840        annotations(
841            read_only_hint = true,
842            destructive_hint = false,
843            idempotent_hint = true,
844            open_world_hint = false
845        )
846    )]
847    async fn check_ipc_integrity(
848        &self,
849        Parameters(params): Parameters<IpcIntegrityParams>,
850    ) -> CallToolResult {
851        let threshold = params.stale_threshold_ms.unwrap_or(5000);
852        let code = ipc_integrity_js(threshold);
853        self.eval_bridge(&code, params.webview_label.as_deref())
854            .await
855    }
856
857    #[tool(
858        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.",
859        annotations(
860            read_only_hint = false,
861            destructive_hint = true,
862            idempotent_hint = false,
863            open_world_hint = false
864        )
865    )]
866    async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
867        let timeout_ms = params.timeout_ms.unwrap_or(10_000).min(120_000);
868        // Clamp BEFORE the value reaches JS: a poll longer than the wait is meaningless, and
869        // one past 2^31 ms overflows `setTimeout` (which then fires immediately, in a loop).
870        let poll = params.poll_ms.unwrap_or(200).clamp(20, timeout_ms.max(20));
871
872        // The `expression` and `event` conditions are awaited server-side (they
873        // poll the eval engine and the captured event bus respectively), so a
874        // fire-and-forget backend command can be awaited to true completion.
875        match params.condition {
876            WaitCondition::Expression => {
877                return self.wait_for_expression(&params, timeout_ms, poll).await;
878            }
879            WaitCondition::Event => {
880                return self.wait_for_event(&params, timeout_ms, poll).await;
881            }
882            _ => {}
883        }
884
885        let value = params
886            .value
887            .as_ref()
888            .map_or_else(|| "null".to_string(), |v| js_string(v));
889        let code = format!(
890            "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
891            js_string(params.condition.as_str())
892        );
893        let eval_timeout = std::time::Duration::from_millis(timeout_ms + 5000);
894        match self
895            .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
896            .await
897        {
898            Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
899            Err(e) => tool_error(e),
900        }
901    }
902
903    /// Poll a JS expression until truthy (or `== expected`), server-side.
904    ///
905    /// Level-triggered and race-free: each poll re-evaluates the expression via
906    /// the same engine as `eval_js`, so it may `await`. Eval errors are treated
907    /// as "not yet met" (the target may not exist during startup) and the last
908    /// error is surfaced on timeout.
909    async fn wait_for_expression(
910        &self,
911        params: &WaitForParams,
912        timeout_ms: u64,
913        poll_ms: u64,
914    ) -> CallToolResult {
915        if !self.state.privacy.is_tool_enabled("eval_js") {
916            return tool_disabled("wait_for(expression) requires eval_js capability");
917        }
918        let Some(expr) = params.value.as_deref().filter(|s| !s.is_empty()) else {
919            return missing_param("value", "wait_for(expression)");
920        };
921        let code = expression_eval_code(expr);
922        let start = std::time::Instant::now();
923        let deadline = start + std::time::Duration::from_millis(timeout_ms);
924        let poll = std::time::Duration::from_millis(poll_ms);
925        let mut last_value = serde_json::Value::Null;
926        let mut last_error: Option<String> = None;
927
928        loop {
929            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
930            let per_eval = remaining
931                .min(std::time::Duration::from_secs(15))
932                .max(std::time::Duration::from_secs(1));
933            match self
934                .eval_outcome(&code, params.webview_label.as_deref(), per_eval)
935                .await
936            {
937                // Code that does not parse can never start matching: fail now instead of
938                // polling to the timeout.
939                Err(f) if f.is_parse_error() => {
940                    return tool_error(format!("wait_for(expression): {}", f.message));
941                }
942                Ok(raw) => {
943                    let val = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null);
944                    let met = match &params.expected {
945                        Some(expected) => &val == expected,
946                        None => json_truthy(&val),
947                    };
948                    if met {
949                        return json_result(&serde_json::json!({
950                            "ok": true,
951                            "value": val,
952                            "elapsed_ms": start.elapsed().as_millis() as u64,
953                        }));
954                    }
955                    last_value = val;
956                }
957                Err(e) => last_error = Some(e.message),
958            }
959
960            if std::time::Instant::now() >= deadline {
961                return json_result(&serde_json::json!({
962                    "ok": false,
963                    "error": format!("timeout after {timeout_ms}ms"),
964                    "last_value": last_value,
965                    "last_error": last_error,
966                    "elapsed_ms": start.elapsed().as_millis() as u64,
967                }));
968            }
969            tokio::time::sleep(
970                poll.min(deadline.saturating_duration_since(std::time::Instant::now())),
971            )
972            .await;
973        }
974    }
975
976    /// Block until a named Tauri event appears on the captured event bus.
977    ///
978    /// Edge-triggered: matches the most recent event whose timestamp is no older
979    /// than `since_ms` before this call began, so an event fired in the gap
980    /// between `invoke_command` and this call is still caught. Polls the
981    /// event-bus ring buffer — no webview eval involved.
982    async fn wait_for_event(
983        &self,
984        params: &WaitForParams,
985        timeout_ms: u64,
986        poll_ms: u64,
987    ) -> CallToolResult {
988        let Some(name) = params.value.as_deref().filter(|s| !s.is_empty()) else {
989            return missing_param("value", "wait_for(event)");
990        };
991        let since_ms = params.since_ms.unwrap_or(2000);
992        let start = std::time::Instant::now();
993        let baseline = bounded::ms_ago(chrono::Utc::now(), since_ms);
994        let deadline = start + std::time::Duration::from_millis(timeout_ms);
995        let poll = std::time::Duration::from_millis(poll_ms);
996
997        loop {
998            // Search newest-first for a matching event no older than the baseline.
999            let matched = self.state.event_bus.events().into_iter().rev().find(|e| {
1000                e.name == name
1001                    && chrono::DateTime::parse_from_rfc3339(&e.timestamp)
1002                        .map_or(true, |ts| ts.with_timezone(&chrono::Utc) >= baseline)
1003            });
1004            if let Some(ev) = matched {
1005                return json_result(&serde_json::json!({
1006                    "ok": true,
1007                    "event": {
1008                        "name": ev.name,
1009                        "payload": ev.payload,
1010                        "timestamp": ev.timestamp,
1011                    },
1012                    "elapsed_ms": start.elapsed().as_millis() as u64,
1013                }));
1014            }
1015            if std::time::Instant::now() >= deadline {
1016                return json_result(&serde_json::json!({
1017                    "ok": false,
1018                    "error": format!("timeout after {timeout_ms}ms waiting for event '{name}'"),
1019                    "hint": "Ensure the app emits this Tauri event and Victauri captures it: \
1020                             custom events need VictauriBuilder::listen_events(&[\"…\"]); \
1021                             window-lifecycle events are captured automatically.",
1022                    "elapsed_ms": start.elapsed().as_millis() as u64,
1023                }));
1024            }
1025            // Never sleep past the deadline (a full poll used to overshoot a short timeout).
1026            tokio::time::sleep(
1027                poll.min(deadline.saturating_duration_since(std::time::Instant::now())),
1028            )
1029            .await;
1030        }
1031    }
1032
1033    #[tool(
1034        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. A result of `undefined` is treated as `null` (JSON has no undefined; like JS `x == null`): `exists` is false, `falsy` is true, `equals null` and `type_is \"null\"` pass.",
1035        annotations(
1036            read_only_hint = false,
1037            destructive_hint = true,
1038            idempotent_hint = false,
1039            open_world_hint = false
1040        )
1041    )]
1042    async fn assert_semantic(
1043        &self,
1044        Parameters(params): Parameters<SemanticAssertParams>,
1045    ) -> CallToolResult {
1046        if !self.state.privacy.is_tool_enabled("eval_js") {
1047            return tool_disabled("assert_semantic requires eval_js capability");
1048        }
1049        let code = expression_eval_code(&params.expression);
1050        let actual_json = match self
1051            .eval_with_return(&code, params.webview_label.as_deref())
1052            .await
1053        {
1054            Ok(result) => result,
1055            Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
1056        };
1057
1058        let actual: serde_json::Value = match parse_expression_value(&actual_json) {
1059            Ok(v) => v,
1060            Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
1061        };
1062
1063        let assertion =
1064            victauri_core::SemanticAssertion::new(params.label, params.condition, params.expected);
1065
1066        let result = victauri_core::evaluate_assertion(actual, &assertion);
1067        json_result(&result)
1068    }
1069
1070    #[tool(
1071        description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.",
1072        annotations(
1073            read_only_hint = true,
1074            destructive_hint = false,
1075            idempotent_hint = true,
1076            open_world_hint = false
1077        )
1078    )]
1079    async fn resolve_command(
1080        &self,
1081        Parameters(params): Parameters<ResolveCommandParams>,
1082    ) -> CallToolResult {
1083        let limit = params.limit.unwrap_or(5);
1084        let mut results = self.state.registry.resolve(&params.query);
1085        results.truncate(limit);
1086        json_result(&results)
1087    }
1088
1089    #[tool(
1090        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.",
1091        annotations(
1092            read_only_hint = true,
1093            destructive_hint = false,
1094            idempotent_hint = true,
1095            open_world_hint = false
1096        )
1097    )]
1098    async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
1099        let commands = match params.query {
1100            Some(q) => self.state.registry.search(&q),
1101            None => self.state.registry.list(),
1102        };
1103        json_result(&commands)
1104    }
1105
1106    #[tool(
1107        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).",
1108        annotations(
1109            read_only_hint = true,
1110            destructive_hint = false,
1111            idempotent_hint = true,
1112            open_world_hint = false
1113        )
1114    )]
1115    async fn app_state(&self, Parameters(params): Parameters<AppStateParams>) -> CallToolResult {
1116        let Some(name) = params.probe else {
1117            return json_result(&serde_json::json!({ "probes": self.state.probes.names() }));
1118        };
1119        if let Some(probe) = self.state.probes.get(&name) {
1120            // A probe is app code: run it off the async executor, bounded, and with a panic
1121            // boundary (it used to run inline on a tokio worker with neither). Its slot is
1122            // held by the probe's thread, so a hung probe that outlives the deadline still
1123            // counts against the cap and repeated calls cannot pile up leaked threads.
1124            let Ok(slot) = Arc::clone(&self.probe_slots).try_acquire_owned() else {
1125                return tool_error(format!(
1126                    "probe '{name}' not run: app probes are busy ({MAX_CONCURRENT_PROBES} \
1127                     still running — a probe that hangs keeps running past its timeout). \
1128                     Retry shortly."
1129                ));
1130            };
1131            match bounded::run_blocking_bounded(
1132                None,
1133                &format!("probe '{name}'"),
1134                self.probe_timeout,
1135                move || {
1136                    let _slot = slot;
1137                    Ok(probe())
1138                },
1139            )
1140            .await
1141            {
1142                Ok(value) => json_result(&value),
1143                Err(e) => tool_error(e),
1144            }
1145        } else {
1146            let available = self.state.probes.names();
1147            tool_error_with_hint(
1148                format!(
1149                    "unknown probe '{name}'. Available probes: {}",
1150                    if available.is_empty() {
1151                        "(none registered — add VictauriBuilder::probe(\"name\", ...))".to_string()
1152                    } else {
1153                        available.join(", ")
1154                    }
1155                ),
1156                RecoveryHint::CheckInput,
1157            )
1158        }
1159    }
1160
1161    #[tool(
1162        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.",
1163        annotations(
1164            read_only_hint = true,
1165            destructive_hint = false,
1166            idempotent_hint = true,
1167            open_world_hint = false
1168        )
1169    )]
1170    async fn get_memory_stats(&self) -> CallToolResult {
1171        let stats = crate::memory::current_stats();
1172        json_result(&stats)
1173    }
1174
1175    #[tool(
1176        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.",
1177        annotations(
1178            read_only_hint = true,
1179            destructive_hint = false,
1180            idempotent_hint = true,
1181            open_world_hint = false
1182        )
1183    )]
1184    async fn get_plugin_info(&self) -> CallToolResult {
1185        let disabled: Vec<&str> = self
1186            .state
1187            .privacy
1188            .disabled_tools
1189            .iter()
1190            .map(std::string::String::as_str)
1191            .collect();
1192        let blocklist: Vec<&str> = self
1193            .state
1194            .privacy
1195            .command_blocklist
1196            .iter()
1197            .map(std::string::String::as_str)
1198            .collect();
1199        let allowlist: Option<Vec<&str>> = self
1200            .state
1201            .privacy
1202            .command_allowlist
1203            .as_ref()
1204            .map(|s| s.iter().map(std::string::String::as_str).collect());
1205        let all_tools = Self::tool_router().list_all();
1206        let enabled_tools: Vec<&str> = all_tools
1207            .iter()
1208            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
1209            .map(|t| t.name.as_ref())
1210            .collect();
1211
1212        // Host-app identity: lets an agent verify on its FIRST call that it reached the
1213        // intended app (not another Victauri instance sharing the discovery port).
1214        let app_cfg = self.bridge.tauri_config();
1215        let result = serde_json::json!({
1216            "version": env!("CARGO_PKG_VERSION"),
1217            "bridge_version": BRIDGE_VERSION,
1218            "port": self.state.port.load(Ordering::Relaxed),
1219            "app": {
1220                "identifier": app_cfg.get("identifier"),
1221                "product_name": app_cfg.get("product_name"),
1222            },
1223            "tools": {
1224                "total": all_tools.len(),
1225                "enabled": enabled_tools.len(),
1226                "enabled_list": enabled_tools,
1227                "disabled_list": disabled,
1228            },
1229            "commands": {
1230                "allowlist": allowlist,
1231                "blocklist": blocklist,
1232            },
1233            "privacy": {
1234                "profile": self.state.privacy.profile.to_string(),
1235                "redaction_enabled": self.state.privacy.redaction_enabled,
1236            },
1237            "capacities": {
1238                "event_log": self.state.event_log.capacity(),
1239                "eval_timeout_secs": self.state.eval_timeout.as_secs(),
1240            },
1241            "registered_commands": self.state.registry.count(),
1242            "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
1243            "uptime_secs": self.state.started_at.elapsed().as_secs(),
1244        });
1245        json_result(&result)
1246    }
1247
1248    #[tool(
1249        description = "Run environment diagnostics: IPC capture completeness (`info.ipc_capture`: the Tauri IPC transport in use — fetch, or the postMessage fallback Tauri switches to after a custom-protocol failure — and whether capture is full, requests-only or none), 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.",
1250        annotations(
1251            read_only_hint = true,
1252            destructive_hint = false,
1253            idempotent_hint = true,
1254            open_world_hint = false
1255        )
1256    )]
1257    async fn get_diagnostics(
1258        &self,
1259        Parameters(params): Parameters<DiagnosticsParams>,
1260    ) -> CallToolResult {
1261        self.eval_bridge(
1262            "return window.__VICTAURI__?.getDiagnostics()",
1263            params.webview_label.as_deref(),
1264        )
1265        .await
1266    }
1267
1268    // ── Backend Access Tools ───────────────────────────────────────────────
1269
1270    #[tool(
1271        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.",
1272        annotations(
1273            read_only_hint = true,
1274            destructive_hint = false,
1275            idempotent_hint = true,
1276            open_world_hint = false
1277        )
1278    )]
1279    async fn app_info(&self) -> CallToolResult {
1280        let config = self.bridge.tauri_config();
1281
1282        let data_dir = self.bridge.app_data_dir().ok();
1283        let config_dir = self.bridge.app_config_dir().ok();
1284        let log_dir = self.bridge.app_log_dir().ok();
1285        let local_data_dir = self.bridge.app_local_data_dir().ok();
1286
1287        let env_vars: std::collections::BTreeMap<String, String> = std::env::vars()
1288            .filter(|(k, _)| is_safe_env_key(k))
1289            .collect();
1290
1291        // Enumerate every database candidate across ALL roots (configured db_search_paths
1292        // + every OS app dir), each tagged with size, whether it is a WebView/engine
1293        // internal store, and whether it is the one `query_db` would auto-select. This lets
1294        // an agent see and disambiguate the real app DB instead of guessing (audit /
1295        // red-team "wrong DB" finding — `app_info.databases` previously only walked
1296        // data_dir and returned bare relative names).
1297        #[cfg(feature = "sqlite")]
1298        let databases: Vec<serde_json::Value> = {
1299            let mut all_dirs: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
1300            for d in [
1301                data_dir.as_ref(),
1302                config_dir.as_ref(),
1303                log_dir.as_ref(),
1304                local_data_dir.as_ref(),
1305            ]
1306            .into_iter()
1307            .flatten()
1308            {
1309                all_dirs.push(d.clone());
1310            }
1311            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
1312                all_dirs.clone()
1313            } else {
1314                self.state.db_search_paths.clone()
1315            };
1316            let selected = crate::database::select_app_database(&select_dirs).ok();
1317            crate::database::classify_databases(&all_dirs)
1318                .into_iter()
1319                .map(|c| {
1320                    serde_json::json!({
1321                        "path": c.path.to_string_lossy(),
1322                        "size_bytes": c.size_bytes,
1323                        "webview_internal": c.webview_internal,
1324                        "selected": selected.as_ref() == Some(&c.path),
1325                    })
1326                })
1327                .collect()
1328        };
1329
1330        #[cfg(not(feature = "sqlite"))]
1331        let databases: Vec<serde_json::Value> = Vec::new();
1332
1333        let result = serde_json::json!({
1334            "config": config,
1335            "paths": {
1336                "data": data_dir.as_ref().map(|p| p.to_string_lossy()),
1337                "config": config_dir.as_ref().map(|p| p.to_string_lossy()),
1338                "log": log_dir.as_ref().map(|p| p.to_string_lossy()),
1339                "local_data": local_data_dir.as_ref().map(|p| p.to_string_lossy()),
1340            },
1341            "databases": databases,
1342            "env": env_vars,
1343            "process": {
1344                "pid": std::process::id(),
1345                "arch": std::env::consts::ARCH,
1346                "os": std::env::consts::OS,
1347                "family": std::env::consts::FAMILY,
1348            },
1349        });
1350        json_result(&result)
1351    }
1352
1353    #[tool(
1354        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.",
1355        annotations(
1356            read_only_hint = true,
1357            destructive_hint = false,
1358            idempotent_hint = true,
1359            open_world_hint = false
1360        )
1361    )]
1362    async fn list_app_dir(
1363        &self,
1364        Parameters(params): Parameters<ListAppDirParams>,
1365    ) -> CallToolResult {
1366        let base = match self.resolve_app_dir(params.directory) {
1367            Ok(d) => d,
1368            Err(e) => return tool_error(e),
1369        };
1370
1371        // Lexical traversal guard BEFORE any filesystem access: a `..` or absolute sub-path
1372        // is rejected as traversal up front.
1373        if let Some(sub) = params.path.as_deref()
1374            && let Err(e) = Self::lexical_safe(std::path::Path::new(sub))
1375        {
1376            return tool_error(e);
1377        }
1378        // Every filesystem call below is synchronous and unbounded in the directory's size, so
1379        // the whole walk runs on the blocking pool (it used to run on an async worker).
1380        let walk = tokio::task::spawn_blocking(move || Self::list_app_dir_blocking(&base, &params));
1381        match walk.await {
1382            Ok(Ok(listing)) => json_result(&listing),
1383            Ok(Err(e)) => tool_error(e),
1384            Err(e) => tool_error(format!("directory listing task failed: {e}")),
1385        }
1386    }
1387
1388    fn list_app_dir_blocking(
1389        base: &std::path::Path,
1390        params: &ListAppDirParams,
1391    ) -> Result<serde_json::Value, String> {
1392        let sub = params.path.clone().unwrap_or_default();
1393        let target = base.join(&sub);
1394        // A missing directory is a normal, non-error result — unless the path escapes the
1395        // base through a symlink, which is refused whether or not its target exists (so the
1396        // listing is no oracle for paths outside the base).
1397        if !Self::contained_or_missing(base, &target)? {
1398            return Ok(serde_json::json!({
1399                "base": base.to_string_lossy(),
1400                "path": sub,
1401                "exists": false,
1402                "entries": [],
1403                "count": 0,
1404            }));
1405        }
1406        let canon_base = std::fs::canonicalize(base)
1407            .map_err(|e| format!("cannot resolve base directory: {e}"))?;
1408
1409        let mut walk = DirWalk {
1410            canon_base,
1411            pattern: params.pattern.clone(),
1412            max_depth: params.max_depth.unwrap_or(1).min(5),
1413            deadline: std::time::Instant::now() + MAX_DIR_WALK_TIME,
1414            max_visited: MAX_DIR_VISITED,
1415            visited: 0,
1416            truncated: false,
1417            entries: Vec::new(),
1418        };
1419        walk.visit(&target, base, 0);
1420
1421        Ok(serde_json::json!({
1422            "base": base.to_string_lossy(),
1423            "path": sub,
1424            "exists": true,
1425            "count": walk.entries.len(),
1426            "entries": walk.entries,
1427            "truncated": walk.truncated,
1428        }))
1429    }
1430
1431    #[tool(
1432        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.",
1433        annotations(
1434            read_only_hint = true,
1435            destructive_hint = false,
1436            idempotent_hint = true,
1437            open_world_hint = false
1438        )
1439    )]
1440    async fn read_app_file(
1441        &self,
1442        Parameters(params): Parameters<ReadAppFileParams>,
1443    ) -> CallToolResult {
1444        let base = match self.resolve_app_dir(params.directory) {
1445            Ok(d) => d,
1446            Err(e) => return tool_error(e),
1447        };
1448
1449        // Lexical traversal guard FIRST — before the existence check — so a
1450        // traversal attempt (`..` / absolute) is rejected as traversal rather
1451        // than leaking whether the out-of-tree target exists via "file not
1452        // found". `safe_within` (which canonicalizes) stays below as
1453        // defense-in-depth for real files.
1454        if let Err(e) = Self::lexical_safe(std::path::Path::new(&params.path)) {
1455            return tool_error(e);
1456        }
1457        let target = base.join(&params.path);
1458        // Refused as traversal whether or not a symlinked-out target exists (no oracle).
1459        match Self::contained_or_missing(&base, &target) {
1460            Ok(true) => {}
1461            Ok(false) => return tool_error(format!("file not found: {}", params.path)),
1462            Err(e) => return tool_error(e),
1463        }
1464        if let Err(e) = Self::safe_within(&base, &target) {
1465            return tool_error(e);
1466        }
1467        if !target.is_file() {
1468            return tool_error(format!("not a file: {}", params.path));
1469        }
1470
1471        let max_bytes = params.max_bytes.unwrap_or(1_048_576).min(10_485_760);
1472
1473        // Open the CANONICAL validated path, and do the blocking file IO on the blocking pool.
1474        // Doing sync `std::fs` IO directly in this async fn could stall the executor thread if a
1475        // regular file were swapped (between the `safe_within` check and the open) for a FIFO or
1476        // slow device whose `read_to_end` blocks; opening the canonical path also closes the
1477        // trivial validate-lexical / open-lexical symlink-swap window.
1478        let canonical = match std::fs::canonicalize(&target) {
1479            Ok(c) => c,
1480            Err(e) => return tool_error(format!("cannot resolve path: {e}")),
1481        };
1482        let (mut bytes, original_size, modified) =
1483            match self.read_regular_file_bounded(canonical, max_bytes).await {
1484                Ok(v) => v,
1485                Err(e) => return tool_error(format!("failed to read file: {e}")),
1486            };
1487        let truncated = bytes.len() > max_bytes;
1488        if truncated {
1489            bytes.truncate(max_bytes);
1490        }
1491
1492        let file_info = serde_json::json!({
1493            "path": params.path,
1494            "size": original_size,
1495            "truncated": truncated,
1496            "modified": modified,
1497        });
1498
1499        if params.binary == Some(true) {
1500            use base64::Engine;
1501            let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1502            json_result(&serde_json::json!({
1503                "file": file_info,
1504                "encoding": "base64",
1505                "content": b64,
1506            }))
1507        } else {
1508            // Truncation can split a multi-byte character; that is not a non-UTF-8 file.
1509            // `error_len() == None` means the bytes end mid-character, so drop the partial
1510            // character instead of returning the whole read as base64.
1511            if truncated
1512                && let Err(e) = std::str::from_utf8(&bytes)
1513                && e.error_len().is_none()
1514            {
1515                bytes.truncate(e.valid_up_to());
1516            }
1517            match String::from_utf8(bytes) {
1518                Ok(text) => json_result(&serde_json::json!({
1519                    "file": file_info,
1520                    "encoding": "utf-8",
1521                    "content": text,
1522                })),
1523                Err(e) => {
1524                    use base64::Engine;
1525                    let bytes = e.into_bytes();
1526                    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1527                    json_result(&serde_json::json!({
1528                        "file": file_info,
1529                        "encoding": "base64",
1530                        "note": "file is not valid UTF-8, returning base64",
1531                        "content": b64,
1532                    }))
1533                }
1534            }
1535        }
1536    }
1537
1538    #[tool(
1539        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.",
1540        annotations(
1541            read_only_hint = true,
1542            destructive_hint = false,
1543            idempotent_hint = true,
1544            open_world_hint = false
1545        )
1546    )]
1547    async fn query_db(&self, Parameters(params): Parameters<QueryDbParams>) -> CallToolResult {
1548        // query_db is ALWAYS registered as a tool so the rmcp `#[tool_router]` macro
1549        // compiles with `default-features = false` (a consumer that drops the heavy
1550        // rusqlite C dependency). The actual SQLite implementation only exists with the
1551        // `sqlite` feature; without it, return a clear, actionable error.
1552        #[cfg(feature = "sqlite")]
1553        {
1554            self.query_db_impl(params).await
1555        }
1556        #[cfg(not(feature = "sqlite"))]
1557        {
1558            let _ = params;
1559            tool_error(
1560                "query_db is unavailable: this build was compiled without the 'sqlite' \
1561                 feature (default-features = false). Re-enable the 'sqlite' feature to use it.",
1562            )
1563        }
1564    }
1565
1566    /// Real `query_db` implementation — compiled only with the `sqlite` feature.
1567    #[cfg(feature = "sqlite")]
1568    async fn query_db_impl(&self, params: QueryDbParams) -> CallToolResult {
1569        // A refused query is refused for what it is, before any database is looked up: the
1570        // error used to depend on whether the app HAS a database ("no application database"
1571        // for a DELETE on an app without one), and it touched the filesystem for nothing.
1572        if let Err(e) = crate::database::validate_query(&params.query) {
1573            return tool_error(e);
1574        }
1575        let data_dir = match self.bridge.app_data_dir() {
1576            Ok(d) => d,
1577            Err(e) => return tool_error(format!("cannot access app data directory: {e}")),
1578        };
1579
1580        let search_dirs = self.db_roots();
1581
1582        let db_path = if let Some(ref requested_path) = params.path {
1583            match Self::resolve_existing_db_path(&search_dirs, requested_path) {
1584                Ok(path) => path,
1585                Err(e) => return tool_error(e),
1586            }
1587        } else {
1588            // Auto-select the application DB. When db_search_paths is configured it is
1589            // EXCLUSIVE — never fall back to OS app dirs (which hold WebView internals),
1590            // so a configured-but-empty root yields a clear error instead of silently
1591            // querying the wrong database. WebView/browser-engine internal stores are
1592            // excluded and the largest remaining candidate wins (audit / red-team "wrong
1593            // DB" finding).
1594            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
1595                search_dirs.clone()
1596            } else {
1597                self.state.db_search_paths.clone()
1598            };
1599            match crate::database::select_app_database(&select_dirs) {
1600                Ok(p) => p,
1601                Err(e) => return tool_error(e),
1602            }
1603        };
1604
1605        let db_display = db_path
1606            .strip_prefix(&data_dir)
1607            .unwrap_or(&db_path)
1608            .to_string_lossy()
1609            .into_owned();
1610        let bind_params = params.params.unwrap_or_default();
1611        let query = params.query;
1612        let max_rows = params.max_rows;
1613
1614        match bounded::run_blocking_bounded(
1615            Some(&bounded::DB_SLOTS),
1616            "database query",
1617            crate::database::QUERY_TIMEOUT + bounded::BLOCKING_DEADLINE_SLACK,
1618            move || crate::database::query(&db_path, &query, &bind_params, max_rows),
1619        )
1620        .await
1621        {
1622            Ok(mut result) => {
1623                if let Some(obj) = result.as_object_mut() {
1624                    obj.insert("database".to_string(), serde_json::json!(db_display));
1625                }
1626                json_result(&result)
1627            }
1628            Err(e) => tool_error(e),
1629        }
1630    }
1631
1632    /// Roots a `query_db` / `db_health` database `path` resolves against, in precedence
1633    /// order: configured `db_search_paths` (so a configured app DB beats incidental ones such
1634    /// as `WebView` internals), then the app's data, config, local-data and log directories.
1635    /// De-duplicated keeping the FIRST occurrence: a relative path present under two roots
1636    /// always resolves to the same file (a `HashSet` made that nondeterministic), and both
1637    /// tools search the same places (`db_health` used to skip the log directory).
1638    #[cfg(feature = "sqlite")]
1639    fn db_roots(&self) -> Vec<std::path::PathBuf> {
1640        let mut roots: Vec<std::path::PathBuf> = Vec::new();
1641        let app_dirs = [
1642            self.bridge.app_data_dir(),
1643            self.bridge.app_config_dir(),
1644            self.bridge.app_local_data_dir(),
1645            self.bridge.app_log_dir(),
1646        ];
1647        let candidates = self
1648            .state
1649            .db_search_paths
1650            .iter()
1651            .cloned()
1652            .chain(app_dirs.into_iter().filter_map(Result::ok));
1653        for dir in candidates {
1654            if !roots.contains(&dir) {
1655                roots.push(dir);
1656            }
1657        }
1658        roots
1659    }
1660
1661    // ── Compound Tools ──────────────────────────────────────────────────────
1662
1663    #[tool(
1664        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. An action the page refuses (element covered, disabled, hidden, detached, or ref not found) returns an error with the reason and a [hint: RETRY_LATER|CHECK_INPUT].",
1665        annotations(
1666            read_only_hint = false,
1667            destructive_hint = false,
1668            idempotent_hint = false,
1669            open_world_hint = false
1670        )
1671    )]
1672    async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
1673        if !self.state.privacy.is_tool_enabled("interact") {
1674            return tool_disabled("interact");
1675        }
1676        match params.action {
1677            InteractAction::Click => {
1678                if !self.state.privacy.is_tool_enabled("interact.click") {
1679                    return tool_disabled("interact.click");
1680                }
1681                let Some(ref_id) = &params.ref_id else {
1682                    return missing_param("ref_id", "click");
1683                };
1684                if params.trusted.unwrap_or(false) {
1685                    // Resolve the click point in the top window's viewport (frame offsets
1686                    // added), refusing a disabled/hidden/covered/off-screen element — a real
1687                    // OS click lands on whatever is on screen there — then deliver it.
1688                    let probe = trusted_click_probe_js(ref_id);
1689                    let raw = match self
1690                        .eval_with_return(&probe, params.webview_label.as_deref())
1691                        .await
1692                    {
1693                        Ok(r) => r,
1694                        Err(e) => return tool_error(e),
1695                    };
1696                    let point = serde_json::from_str::<serde_json::Value>(&raw).unwrap_or_default();
1697                    if let Some(why) = point.get("error").and_then(serde_json::Value::as_str) {
1698                        return tool_error_with_hint(
1699                            format!(
1700                                "trusted click on {ref_id} refused: {why} — no OS click was sent"
1701                            ),
1702                            RecoveryHint::CheckInput,
1703                        );
1704                    }
1705                    let (Some(x), Some(y)) = (
1706                        point.get("x").and_then(serde_json::Value::as_f64),
1707                        point.get("y").and_then(serde_json::Value::as_f64),
1708                    ) else {
1709                        return tool_error_with_hint(
1710                            format!("ref not found: {ref_id}"),
1711                            RecoveryHint::CheckInput,
1712                        );
1713                    };
1714                    if !(x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0) {
1715                        return tool_error_with_hint(
1716                            format!(
1717                                "trusted click on {ref_id} refused: the page reported an \
1718                                 unusable click point ({x}, {y}) — no OS click was sent"
1719                            ),
1720                            RecoveryHint::CheckInput,
1721                        );
1722                    }
1723                    let bridge = self.bridge.clone();
1724                    let label = params.webview_label.clone();
1725                    let native = tokio::task::spawn_blocking(move || {
1726                        bridge.native_click(label.as_deref(), x, y)
1727                    })
1728                    .await
1729                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1730                    return match native {
1731                        Ok(()) => json_result(
1732                            &serde_json::json!({"ok": true, "trusted": true, "x": x, "y": y}),
1733                        ),
1734                        Err(e) => tool_error(e),
1735                    };
1736                }
1737                let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
1738                self.eval_page_action(&code, params.webview_label.as_deref())
1739                    .await
1740            }
1741            InteractAction::DoubleClick => {
1742                if !self.state.privacy.is_tool_enabled("interact.double_click") {
1743                    return tool_disabled("interact.double_click");
1744                }
1745                let Some(ref_id) = &params.ref_id else {
1746                    return missing_param("ref_id", "double_click");
1747                };
1748                let code = format!(
1749                    "return window.__VICTAURI__?.doubleClick({})",
1750                    js_string(ref_id)
1751                );
1752                self.eval_page_action(&code, params.webview_label.as_deref())
1753                    .await
1754            }
1755            InteractAction::Hover => {
1756                if !self.state.privacy.is_tool_enabled("interact.hover") {
1757                    return tool_disabled("interact.hover");
1758                }
1759                let Some(ref_id) = &params.ref_id else {
1760                    return missing_param("ref_id", "hover");
1761                };
1762                let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
1763                self.eval_page_action(&code, params.webview_label.as_deref())
1764                    .await
1765            }
1766            InteractAction::Focus => {
1767                if !self.state.privacy.is_tool_enabled("interact.focus") {
1768                    return tool_disabled("interact.focus");
1769                }
1770                let Some(ref_id) = &params.ref_id else {
1771                    return missing_param("ref_id", "focus");
1772                };
1773                let code = format!(
1774                    "return window.__VICTAURI__?.focusElement({})",
1775                    js_string(ref_id)
1776                );
1777                self.eval_page_action(&code, params.webview_label.as_deref())
1778                    .await
1779            }
1780            InteractAction::ScrollIntoView => {
1781                if !self
1782                    .state
1783                    .privacy
1784                    .is_tool_enabled("interact.scroll_into_view")
1785                {
1786                    return tool_disabled("interact.scroll_into_view");
1787                }
1788                let ref_arg = params
1789                    .ref_id
1790                    .as_ref()
1791                    .map_or_else(|| "null".to_string(), |r| js_string(r));
1792                let x = params.x.unwrap_or(0.0);
1793                let y = params.y.unwrap_or(0.0);
1794                let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
1795                self.eval_page_action(&code, params.webview_label.as_deref())
1796                    .await
1797            }
1798            InteractAction::SelectOption => {
1799                if !self.state.privacy.is_tool_enabled("interact.select_option") {
1800                    return tool_disabled("interact.select_option");
1801                }
1802                let Some(ref_id) = &params.ref_id else {
1803                    return missing_param("ref_id", "select_option");
1804                };
1805                let values_vec;
1806                let values: &[String] = match (&params.values, &params.value) {
1807                    (Some(v), _) => v,
1808                    (None, Some(v)) => {
1809                        values_vec = vec![v.clone()];
1810                        &values_vec
1811                    }
1812                    (None, None) => &[],
1813                };
1814                let values_json =
1815                    serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
1816                let code = format!(
1817                    "return window.__VICTAURI__?.selectOption({}, {})",
1818                    js_string(ref_id),
1819                    values_json
1820                );
1821                self.eval_page_action(&code, params.webview_label.as_deref())
1822                    .await
1823            }
1824        }
1825    }
1826
1827    #[tool(
1828        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. An input the page refuses (element not fillable, covered, disabled, or ref not found) returns an error with the reason and a [hint: RETRY_LATER|CHECK_INPUT].",
1829        annotations(
1830            read_only_hint = false,
1831            destructive_hint = false,
1832            idempotent_hint = false,
1833            open_world_hint = false
1834        )
1835    )]
1836    async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
1837        match params.action {
1838            InputAction::Fill => {
1839                if !self.state.privacy.is_tool_enabled("fill") {
1840                    return tool_disabled("fill");
1841                }
1842                let Some(ref_id) = &params.ref_id else {
1843                    return missing_param("ref_id", "fill");
1844                };
1845                let Some(value) = &params.value else {
1846                    return missing_param("value", "fill");
1847                };
1848                let code = format!(
1849                    "return window.__VICTAURI__?.fill({}, {})",
1850                    js_string(ref_id),
1851                    js_string(value)
1852                );
1853                self.eval_page_action(&code, params.webview_label.as_deref())
1854                    .await
1855            }
1856            InputAction::TypeText => {
1857                if !self.state.privacy.is_tool_enabled("type_text") {
1858                    return tool_disabled("type_text");
1859                }
1860                let Some(ref_id) = &params.ref_id else {
1861                    return missing_param("ref_id", "type_text");
1862                };
1863                let Some(text) = &params.text else {
1864                    return missing_param("text", "type_text");
1865                };
1866                if params.trusted.unwrap_or(false) {
1867                    // Focus the element via JS — and confirm focus landed on it, since the OS
1868                    // keystrokes go to whatever holds focus — then deliver real OS keystrokes
1869                    // (isTrusted: true) for handlers that reject synthetic events.
1870                    if let Err(refused) = self
1871                        .focus_for_trusted_input(ref_id, params.webview_label.as_deref())
1872                        .await
1873                    {
1874                        return refused;
1875                    }
1876                    let bridge = self.bridge.clone();
1877                    let label = params.webview_label.clone();
1878                    let text = text.to_string();
1879                    let native = tokio::task::spawn_blocking(move || {
1880                        bridge.native_type_text(label.as_deref(), &text)
1881                    })
1882                    .await
1883                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1884                    return match native {
1885                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1886                        Err(e) => tool_error(e),
1887                    };
1888                }
1889                let code = format!(
1890                    "return window.__VICTAURI__?.type({}, {})",
1891                    js_string(ref_id),
1892                    js_string(text)
1893                );
1894                self.eval_page_action(&code, params.webview_label.as_deref())
1895                    .await
1896            }
1897            InputAction::PressKey => {
1898                if !self.state.privacy.is_tool_enabled("input.press_key") {
1899                    return tool_disabled("input.press_key");
1900                }
1901                let Some(key) = &params.key else {
1902                    return missing_param("key", "press_key");
1903                };
1904                if params.trusted.unwrap_or(false) {
1905                    // Optionally focus a target element, then send a real OS key. A failed focus
1906                    // must stop here: the key would otherwise go to whatever holds focus.
1907                    if let Some(ref_id) = &params.ref_id
1908                        && let Err(refused) = self
1909                            .focus_for_trusted_input(ref_id, params.webview_label.as_deref())
1910                            .await
1911                    {
1912                        return refused;
1913                    }
1914                    let bridge = self.bridge.clone();
1915                    let label = params.webview_label.clone();
1916                    let key = key.to_string();
1917                    let native = tokio::task::spawn_blocking(move || {
1918                        bridge.native_key(label.as_deref(), &key)
1919                    })
1920                    .await
1921                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1922                    return match native {
1923                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1924                        Err(e) => tool_error(e),
1925                    };
1926                }
1927                let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
1928                self.eval_page_action(&code, params.webview_label.as_deref())
1929                    .await
1930            }
1931        }
1932    }
1933
1934    #[tool(
1935        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).",
1936        annotations(
1937            read_only_hint = false,
1938            destructive_hint = true,
1939            idempotent_hint = false,
1940            open_world_hint = false
1941        )
1942    )]
1943    async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
1944        match params.action {
1945            WindowAction::GetState => {
1946                let states = match self.bridge.try_get_window_states(params.label.as_deref()) {
1947                    Ok(states) => states,
1948                    Err(e) => return tool_error(ui_busy(&e)),
1949                };
1950                // A specific label that matches no window is an error, not an
1951                // empty array (which reads as "success, no state").
1952                if states.is_empty()
1953                    && let Some(label) = params.label.as_deref()
1954                {
1955                    return tool_error(format!(
1956                        "window not found: '{label}' (use window.list to see available labels)"
1957                    ));
1958                }
1959                json_result(&states)
1960            }
1961            WindowAction::List => match self.bridge.try_list_window_labels() {
1962                Ok(labels) => json_result(&labels),
1963                Err(e) => tool_error(ui_busy(&e)),
1964            },
1965            WindowAction::Introspectability => self.window_introspectability().await,
1966            WindowAction::Manage => {
1967                if !self.state.privacy.is_tool_enabled("window.manage") {
1968                    return tool_disabled("window.manage");
1969                }
1970                let Some(manage_action) = &params.manage_action else {
1971                    return missing_param("manage_action", "manage");
1972                };
1973                match self
1974                    .bridge
1975                    .manage_window(params.label.as_deref(), manage_action.as_str())
1976                {
1977                    Ok(msg) => CallToolResult::success(vec![ContentBlock::text(msg)]),
1978                    Err(e) => tool_error(e),
1979                }
1980            }
1981            WindowAction::Resize => {
1982                if !self.state.privacy.is_tool_enabled("window.resize") {
1983                    return tool_disabled("window.resize");
1984                }
1985                let Some(width) = params.width else {
1986                    return missing_param("width", "resize");
1987                };
1988                let Some(height) = params.height else {
1989                    return missing_param("height", "resize");
1990                };
1991                if width == 0 || height == 0 {
1992                    return tool_error_with_hint(
1993                        format!(
1994                            "invalid window size {width}x{height}: width and height must be > 0"
1995                        ),
1996                        RecoveryHint::CheckInput,
1997                    );
1998                }
1999                match self
2000                    .bridge
2001                    .resize_window(params.label.as_deref(), width, height)
2002                {
2003                    Ok(()) => {
2004                        let result =
2005                            serde_json::json!({"ok": true, "width": width, "height": height});
2006                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2007                    }
2008                    Err(e) => tool_error(e),
2009                }
2010            }
2011            WindowAction::MoveTo => {
2012                if !self.state.privacy.is_tool_enabled("window.move_to") {
2013                    return tool_disabled("window.move_to");
2014                }
2015                let Some(x) = params.x else {
2016                    return missing_param("x", "move_to");
2017                };
2018                let Some(y) = params.y else {
2019                    return missing_param("y", "move_to");
2020                };
2021                match self.bridge.move_window(params.label.as_deref(), x, y) {
2022                    Ok(()) => {
2023                        let result = serde_json::json!({"ok": true, "x": x, "y": y});
2024                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2025                    }
2026                    Err(e) => tool_error(e),
2027                }
2028            }
2029            WindowAction::SetTitle => {
2030                if !self.state.privacy.is_tool_enabled("window.set_title") {
2031                    return tool_disabled("window.set_title");
2032                }
2033                let Some(title) = &params.title else {
2034                    return missing_param("title", "set_title");
2035                };
2036                match self.bridge.set_window_title(params.label.as_deref(), title) {
2037                    Ok(()) => {
2038                        let result = serde_json::json!({"ok": true, "title": title});
2039                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2040                    }
2041                    Err(e) => tool_error(e),
2042                }
2043            }
2044        }
2045    }
2046
2047    #[tool(
2048        description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
2049        annotations(
2050            read_only_hint = false,
2051            destructive_hint = true,
2052            idempotent_hint = false,
2053            open_world_hint = false
2054        )
2055    )]
2056    async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
2057        match params.action {
2058            StorageAction::Get => {
2059                let method = match params.storage_type.unwrap_or(StorageType::Local) {
2060                    StorageType::Session => "getSessionStorage",
2061                    StorageType::Local => "getLocalStorage",
2062                };
2063                let key_arg = params
2064                    .key
2065                    .as_ref()
2066                    .map(|k| js_string(k))
2067                    .unwrap_or_default();
2068                let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
2069                self.eval_bridge(&code, params.webview_label.as_deref())
2070                    .await
2071            }
2072            StorageAction::Set => {
2073                if !self.state.privacy.is_tool_enabled("set_storage") {
2074                    return tool_disabled("set_storage");
2075                }
2076                let method = match params.storage_type.unwrap_or(StorageType::Local) {
2077                    StorageType::Session => "setSessionStorage",
2078                    StorageType::Local => "setLocalStorage",
2079                };
2080                let Some(key) = &params.key else {
2081                    return missing_param("key", "set");
2082                };
2083                // Operator-protected keys (auth/role/tier/flags) can't be poisoned
2084                // via storage.set (audit #33).
2085                if !self.state.privacy.is_storage_key_allowed(key) {
2086                    return tool_error(format!(
2087                        "storage key '{key}' is protected by privacy configuration"
2088                    ));
2089                }
2090                let value = params
2091                    .value
2092                    .as_ref()
2093                    .cloned()
2094                    .unwrap_or(serde_json::Value::Null);
2095                let value_json =
2096                    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
2097                let code = format!(
2098                    "return window.__VICTAURI__?.{method}({}, {value_json})",
2099                    js_string(key)
2100                );
2101                self.eval_bridge(&code, params.webview_label.as_deref())
2102                    .await
2103            }
2104            StorageAction::Delete => {
2105                if !self.state.privacy.is_tool_enabled("delete_storage") {
2106                    return tool_disabled("delete_storage");
2107                }
2108                let method = match params.storage_type.unwrap_or(StorageType::Local) {
2109                    StorageType::Session => "deleteSessionStorage",
2110                    StorageType::Local => "deleteLocalStorage",
2111                };
2112                let Some(key) = &params.key else {
2113                    return missing_param("key", "delete");
2114                };
2115                let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
2116                self.eval_bridge(&code, params.webview_label.as_deref())
2117                    .await
2118            }
2119            StorageAction::GetCookies => {
2120                self.eval_bridge(
2121                    "return window.__VICTAURI__?.getCookies()",
2122                    params.webview_label.as_deref(),
2123                )
2124                .await
2125            }
2126        }
2127    }
2128
2129    #[tool(
2130        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.",
2131        annotations(
2132            read_only_hint = false,
2133            destructive_hint = false,
2134            idempotent_hint = false,
2135            open_world_hint = false
2136        )
2137    )]
2138    async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
2139        match params.action {
2140            NavigateAction::GoTo => {
2141                if !self.state.privacy.is_tool_enabled("navigate") {
2142                    return tool_disabled("navigate");
2143                }
2144                let Some(url) = &params.url else {
2145                    return missing_param("url", "go_to");
2146                };
2147                if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
2148                    return tool_error(e);
2149                }
2150                let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
2151                self.eval_bridge(&code, params.webview_label.as_deref())
2152                    .await
2153            }
2154            NavigateAction::GoBack => {
2155                self.eval_bridge(
2156                    "return window.__VICTAURI__?.navigateBack()",
2157                    params.webview_label.as_deref(),
2158                )
2159                .await
2160            }
2161            NavigateAction::GetHistory => {
2162                self.eval_bridge(
2163                    "return window.__VICTAURI__?.getNavigationLog()",
2164                    params.webview_label.as_deref(),
2165                )
2166                .await
2167            }
2168            NavigateAction::SetDialogResponse => {
2169                if !self.state.privacy.is_tool_enabled("set_dialog_response") {
2170                    return tool_disabled("set_dialog_response");
2171                }
2172                let Some(dialog_type) = params.dialog_type else {
2173                    return missing_param("dialog_type", "set_dialog_response");
2174                };
2175                let Some(dialog_action) = params.dialog_action else {
2176                    return missing_param("dialog_action", "set_dialog_response");
2177                };
2178                let text_arg = params
2179                    .text
2180                    .as_ref()
2181                    .map_or_else(|| "undefined".to_string(), |t| js_string(t));
2182                let code = format!(
2183                    "return {}?.setDialogAutoResponse({}, {}, {text_arg})",
2184                    crate::js_bridge::agent_ops_js(),
2185                    js_string(dialog_type.as_str()),
2186                    js_string(dialog_action.as_str())
2187                );
2188                self.eval_bridge(&code, params.webview_label.as_deref())
2189                    .await
2190            }
2191            NavigateAction::GetDialogLog => {
2192                self.eval_bridge(
2193                    "return window.__VICTAURI__?.getDialogLog()",
2194                    params.webview_label.as_deref(),
2195                )
2196                .await
2197            }
2198        }
2199    }
2200
2201    #[tool(
2202        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 a session from JSON as the active recording; refused while one is in progress), replay (re-invoke the recorded IPC commands that succeeded with no arguments, each in the window that made it — their side effects happen again; calls that had arguments, failed, never completed, were answered by a route rule, or were captured on Tauri's postMessage IPC path are skipped, and webview_label limits replay to that window's calls), flush (immediately drain pending events into recording without waiting for the 1-second poll).",
2203        annotations(
2204            read_only_hint = false,
2205            destructive_hint = true,
2206            idempotent_hint = false,
2207            open_world_hint = false
2208        )
2209    )]
2210    async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
2211        const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
2212        if !self.state.privacy.is_tool_enabled("recording") {
2213            return tool_disabled("recording");
2214        }
2215        match params.action {
2216            RecordingAction::Start => {
2217                let session_id = params
2218                    .session_id
2219                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
2220                let floor_ms = now_ms();
2221                match self.state.recorder.start_session(session_id.clone()) {
2222                    Ok(generation) => {
2223                        self.state.drain_watermarks.reset(floor_ms, generation);
2224                        let result = serde_json::json!({
2225                            "started": true,
2226                            "session_id": session_id,
2227                        });
2228                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2229                    }
2230                    Err(e) => tool_error(e.to_string()),
2231                }
2232            }
2233            RecordingAction::Stop => {
2234                // Final flush first: the background drain reads each window about once a second, so
2235                // anything the page captured since its last tick would otherwise be lost — and under
2236                // a busy UI no drain may have run at all. Best-effort and bounded; a window that
2237                // cannot answer in time is REPORTED, never silently dropped.
2238                let unreachable = if self.state.recorder.is_recording() {
2239                    self.final_recording_flush().await
2240                } else {
2241                    Vec::new()
2242                };
2243                match self.state.recorder.stop() {
2244                    Some(session) => {
2245                        if unreachable.is_empty() {
2246                            json_result(&session)
2247                        } else {
2248                            let mut value =
2249                                serde_json::to_value(&session).unwrap_or(serde_json::Value::Null);
2250                            if let Some(obj) = value.as_object_mut() {
2251                                obj.insert(
2252                                    "final_flush_unreachable".to_string(),
2253                                    serde_json::json!(unreachable),
2254                                );
2255                            }
2256                            json_result(&value)
2257                        }
2258                    }
2259                    None => tool_error("no recording is active"),
2260                }
2261            }
2262            RecordingAction::Checkpoint => {
2263                // checkpoint_id is optional — auto-generate a short id when the
2264                // caller just wants a positional marker. The id is echoed back in
2265                // the response so it can be referenced later
2266                // (events_between_checkpoints / replay).
2267                let id = params
2268                    .checkpoint_id
2269                    .unwrap_or_else(|| format!("cp-{}", uuid::Uuid::new_v4()));
2270                let state = params.state.unwrap_or(serde_json::Value::Null);
2271                match self
2272                    .state
2273                    .recorder
2274                    .checkpoint(id.clone(), params.checkpoint_label, state)
2275                {
2276                    Ok(()) => {
2277                        let result = serde_json::json!({
2278                            "created": true,
2279                            "checkpoint_id": id,
2280                            "event_index": self.state.recorder.event_count(),
2281                        });
2282                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2283                    }
2284                    Err(e) => tool_error(e.to_string()),
2285                }
2286            }
2287            RecordingAction::ListCheckpoints => {
2288                let checkpoints = self.state.recorder.get_checkpoints();
2289                json_result(&checkpoints)
2290            }
2291            RecordingAction::GetEvents => {
2292                let events = self
2293                    .state
2294                    .recorder
2295                    .events_since(params.since_index.unwrap_or(0));
2296                json_result(&events)
2297            }
2298            RecordingAction::EventsBetween => {
2299                let Some(from) = &params.from else {
2300                    return missing_param("from", "events_between");
2301                };
2302                let Some(to) = &params.to else {
2303                    return missing_param("to", "events_between");
2304                };
2305                match self.state.recorder.events_between_checkpoints(from, to) {
2306                    Ok(events) => json_result(&events),
2307                    Err(e) => tool_error(e.to_string()),
2308                }
2309            }
2310            RecordingAction::GetReplay => {
2311                let calls = self.state.recorder.ipc_replay_sequence();
2312                json_result(&calls)
2313            }
2314            RecordingAction::Export => match self.state.recorder.export() {
2315                Some(s) => {
2316                    let json = serde_json::to_string_pretty(&s)
2317                        .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
2318                    CallToolResult::success(vec![ContentBlock::text(json)])
2319                }
2320                None => tool_error("no recording is active — start one first"),
2321            },
2322            RecordingAction::Import => {
2323                let Some(session_json) = &params.session_json else {
2324                    return missing_param("session_json", "import");
2325                };
2326                if session_json.len() > MAX_SESSION_JSON {
2327                    return tool_error("session JSON exceeds maximum size (10 MB)");
2328                }
2329                let session: victauri_core::RecordedSession =
2330                    match serde_json::from_str(session_json) {
2331                        Ok(s) => s,
2332                        Err(e) => return tool_error(format!("invalid session JSON: {e}")),
2333                    };
2334
2335                let result = serde_json::json!({
2336                    "imported": true,
2337                    "session_id": session.id,
2338                    "event_count": session.events.len(),
2339                    "checkpoint_count": session.checkpoints.len(),
2340                    "started_at": session.started_at.to_rfc3339(),
2341                    "recording_active": true,
2342                    "note": "the imported session is now the ACTIVE recording: live events are \
2343                             appended to it until you call recording action=stop",
2344                });
2345                // Import REPLACES the active recording. Doing that silently threw away an
2346                // in-progress recording, so require it to be stopped (and saved) first — checked
2347                // and replaced in one step, so a recording started concurrently is never lost.
2348                let floor_ms = now_ms();
2349                match self.state.recorder.import_if_idle(session) {
2350                    Ok(generation) => self.state.drain_watermarks.reset(floor_ms, generation),
2351                    Err(_) => {
2352                        return tool_error(
2353                            "a recording is in progress — import would discard it. Stop it first \
2354                             (recording action=stop, or export it), then import.",
2355                        );
2356                    }
2357                }
2358                CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2359            }
2360            RecordingAction::Flush => {
2361                if !self.state.recorder.is_recording() {
2362                    return tool_error("no active recording — start a recording first");
2363                }
2364                // Same routine (and the same shared per-window watermark + lock) as the
2365                // background drain, so a flush never re-records what the drain already captured
2366                // and vice versa. With no label, every live window is flushed.
2367                let labels: Vec<String> = match params.webview_label.as_deref() {
2368                    Some(l) => vec![l.to_string()],
2369                    None => match self.bridge.try_list_window_labels() {
2370                        Ok(labels) => labels,
2371                        Err(e) => return tool_error(ui_busy(&e)),
2372                    },
2373                };
2374                let mut captured = 0usize;
2375                let mut failed = Vec::new();
2376                for label in &labels {
2377                    match crate::mcp::server::drain_window_into_recording(
2378                        &self.state,
2379                        &self.bridge,
2380                        label,
2381                    )
2382                    .await
2383                    {
2384                        Some(n) => captured += n,
2385                        None => failed.push(label.clone()),
2386                    }
2387                }
2388                if !labels.is_empty() && failed.len() == labels.len() {
2389                    return tool_error(format!(
2390                        "flush failed: no window answered ({})",
2391                        failed.join(", ")
2392                    ));
2393                }
2394                json_result(&serde_json::json!({
2395                    "flushed": true,
2396                    "events_captured": captured,
2397                    "windows": labels,
2398                    "unreachable_windows": failed,
2399                }))
2400            }
2401            RecordingAction::Replay => {
2402                let calls = self.state.recorder.ipc_replay_sequence();
2403                if calls.is_empty() {
2404                    return tool_error("no IPC calls recorded — record a session first");
2405                }
2406                let mut replay_results = Vec::new();
2407                for call in &calls {
2408                    // Recordings do not capture call ARGUMENTS, so a replay re-invokes each
2409                    // command with none. Re-running a call that took arguments is guaranteed
2410                    // wrong (it fails, or worse, runs with defaults), and re-running a call
2411                    // that failed or never completed reproduces nothing — skip both, loudly.
2412                    // A call a route rule answered in the page never reached the backend:
2413                    // replaying it would turn a fake (possibly page-forged) success into a real
2414                    // invocation.
2415                    let skip_reason = match &call.result {
2416                        _ if call.mocked => Some(
2417                            "the original call was answered by a route rule in the page, not the backend".to_string(),
2418                        ),
2419                        // Observed on Tauri's postMessage path: the bridge saw the call's
2420                        // serialization and response callback, which page script can stage
2421                        // without a real call, so its delivery is unproven.
2422                        _ if call.is_post_message() => Some(
2423                            "the original call was captured on Tauri's postMessage IPC path, where the bridge cannot prove it was really sent".to_string(),
2424                        ),
2425                        victauri_core::IpcResult::Ok(_) if call.arg_size_bytes > 0 => Some(
2426                            "the original call had arguments, which recordings do not capture".to_string(),
2427                        ),
2428                        victauri_core::IpcResult::Ok(_) => params
2429                            .webview_label
2430                            .as_deref()
2431                            .filter(|only| *only != call.webview_label)
2432                            .map(|only| {
2433                                format!(
2434                                    "recorded in window '{}'; replay was limited to '{only}'",
2435                                    call.webview_label
2436                                )
2437                            }),
2438                        victauri_core::IpcResult::Err(_) => {
2439                            Some("the original call failed".to_string())
2440                        }
2441                        _ => Some("the original call never completed".to_string()),
2442                    };
2443                    if let Some(reason) = skip_reason {
2444                        replay_results.push(serde_json::json!({
2445                            "command": call.command,
2446                            "status": "skipped",
2447                            "reason": reason,
2448                        }));
2449                        continue;
2450                    }
2451                    // Enforce the same command allow/blocklist as invoke_command
2452                    // (audit #30/#31): a recorded/imported session must not be able to
2453                    // invoke a command an operator blocked.
2454                    if !self.state.privacy.is_invoke_allowed(&call.command)
2455                        || !self.state.privacy.is_command_allowed(&call.command)
2456                    {
2457                        replay_results.push(serde_json::json!({
2458                            "command": call.command,
2459                            "status": "blocked",
2460                            "error": "blocked by privacy configuration",
2461                        }));
2462                        continue;
2463                    }
2464                    let code = format!(
2465                        "return window.__TAURI_INTERNALS__.invoke({})",
2466                        js_string(&call.command)
2467                    );
2468                    // Replay each call in the window that made it — never a default window: a
2469                    // command recorded in a low-privilege window must not run with main's
2470                    // capabilities. A window that no longer exists fails the call (an explicit
2471                    // label never falls back to another window).
2472                    let outcome = match self
2473                        .eval_with_return(&code, Some(call.webview_label.as_str()))
2474                        .await
2475                    {
2476                        Ok(result_str) => {
2477                            let value: serde_json::Value = serde_json::from_str(&result_str)
2478                                .unwrap_or(serde_json::Value::String(result_str));
2479                            let shape = crate::introspection::JsonShape::from_value(&value);
2480                            serde_json::json!({
2481                                "command": call.command,
2482                                "webview_label": call.webview_label,
2483                                "status": "ok",
2484                                "response_type": shape.type_name(),
2485                            })
2486                        }
2487                        Err(e) => {
2488                            serde_json::json!({
2489                                "command": call.command,
2490                                "status": "error",
2491                                "error": e,
2492                            })
2493                        }
2494                    };
2495                    replay_results.push(outcome);
2496                }
2497                let count = |status: &str| {
2498                    replay_results
2499                        .iter()
2500                        .filter(|r| r.get("status").and_then(|s| s.as_str()) == Some(status))
2501                        .count()
2502                };
2503                let (passed, skipped) = (count("ok"), count("skipped"));
2504                let replayed = replay_results.len() - skipped;
2505                let result = serde_json::json!({
2506                    "replayed": replayed,
2507                    "passed": passed,
2508                    "failed": replayed - passed,
2509                    "skipped": skipped,
2510                    "note": "commands are re-invoked WITHOUT arguments (recordings do not capture \
2511                             them), each in the window that recorded it, and their side effects \
2512                             happen again; calls that had arguments, failed, never completed, \
2513                             were answered by a route rule, or were captured on Tauri's \
2514                             postMessage IPC path are skipped",
2515                    "results": replay_results,
2516                });
2517                json_result(&result)
2518            }
2519        }
2520    }
2521
2522    #[tool(
2523        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).",
2524        annotations(
2525            read_only_hint = false,
2526            destructive_hint = false,
2527            idempotent_hint = true,
2528            open_world_hint = false
2529        )
2530    )]
2531    async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
2532        match params.action {
2533            InspectAction::GetStyles => {
2534                let Some(ref_id) = &params.ref_id else {
2535                    return missing_param("ref_id", "get_styles");
2536                };
2537                let props_arg = match &params.properties {
2538                    Some(props) => {
2539                        let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
2540                        format!("[{}]", arr.join(","))
2541                    }
2542                    None => "null".to_string(),
2543                };
2544                let code = format!(
2545                    "return window.__VICTAURI__?.getStyles({}, {})",
2546                    js_string(ref_id),
2547                    props_arg
2548                );
2549                self.eval_page_action(&code, params.webview_label.as_deref())
2550                    .await
2551            }
2552            InspectAction::GetBoundingBoxes => {
2553                let Some(ref_ids) = &params.ref_ids else {
2554                    return missing_param("ref_ids", "get_bounding_boxes");
2555                };
2556                let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
2557                let code = format!(
2558                    "return window.__VICTAURI__?.getBoundingBoxes([{}])",
2559                    refs.join(",")
2560                );
2561                self.eval_page_action(&code, params.webview_label.as_deref())
2562                    .await
2563            }
2564            InspectAction::Highlight => {
2565                // highlight injects a debug overlay node into the page — a DOM
2566                // mutation — so it is gated separately and excluded from the
2567                // read-only Observe profile (red-team P1).
2568                if !self.state.privacy.is_tool_enabled("inspect.highlight") {
2569                    return tool_disabled("inspect.highlight");
2570                }
2571                let Some(ref_id) = &params.ref_id else {
2572                    return missing_param("ref_id", "highlight");
2573                };
2574                let color_arg = match &params.color {
2575                    Some(c) => match sanitize_css_color(c) {
2576                        Ok(safe) => format!("\"{safe}\""),
2577                        Err(e) => return tool_error(e),
2578                    },
2579                    None => "null".to_string(),
2580                };
2581                let label_arg = match &params.label {
2582                    Some(l) => js_string(l),
2583                    None => "null".to_string(),
2584                };
2585                let code = format!(
2586                    "return window.__VICTAURI__?.highlightElement({}, {}, {})",
2587                    js_string(ref_id),
2588                    color_arg,
2589                    label_arg
2590                );
2591                self.eval_page_action(&code, params.webview_label.as_deref())
2592                    .await
2593            }
2594            InspectAction::ClearHighlights => {
2595                if !self
2596                    .state
2597                    .privacy
2598                    .is_tool_enabled("inspect.clear_highlights")
2599                {
2600                    return tool_disabled("inspect.clear_highlights");
2601                }
2602                self.eval_page_action(
2603                    "return window.__VICTAURI__?.clearHighlights()",
2604                    params.webview_label.as_deref(),
2605                )
2606                .await
2607            }
2608            InspectAction::AuditAccessibility => {
2609                self.eval_page_action(
2610                    "return window.__VICTAURI__?.auditAccessibility()",
2611                    params.webview_label.as_deref(),
2612                )
2613                .await
2614            }
2615            InspectAction::GetPerformance => {
2616                self.eval_page_action(
2617                    "return window.__VICTAURI__?.getPerformanceMetrics()",
2618                    params.webview_label.as_deref(),
2619                )
2620                .await
2621            }
2622        }
2623    }
2624
2625    #[tool(
2626        description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
2627        annotations(
2628            read_only_hint = false,
2629            destructive_hint = false,
2630            idempotent_hint = true,
2631            open_world_hint = false
2632        )
2633    )]
2634    async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
2635        match params.action {
2636            CssAction::Inject => {
2637                if !self.state.privacy.is_tool_enabled("inject_css") {
2638                    return tool_disabled("inject_css");
2639                }
2640                let Some(css) = &params.css else {
2641                    return missing_param("css", "inject");
2642                };
2643                // Block remote @import / url(...) exfil vectors unless explicitly opted in.
2644                if let Err(e) = sanitize_injected_css(css, params.allow_remote) {
2645                    return tool_error(e);
2646                }
2647                let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
2648                self.eval_bridge(&code, params.webview_label.as_deref())
2649                    .await
2650            }
2651            CssAction::Remove => {
2652                if !self.state.privacy.is_tool_enabled("css.remove") {
2653                    return tool_disabled("css.remove");
2654                }
2655                self.eval_bridge(
2656                    "return window.__VICTAURI__?.removeInjectedCss()",
2657                    params.webview_label.as_deref(),
2658                )
2659                .await
2660            }
2661        }
2662    }
2663
2664    #[tool(
2665        description = "Network request interception (Playwright route() equivalent, no CDP). \
2666            Matches webview fetch/XHR by URL and blocks, mocks, or delays them. \
2667            Actions:\n\
2668            - `add`: add a rule. `pattern` (+ optional `match_type`: substring/glob/regex/exact, \
2669              and `method`) selects requests; `behavior` is `block` (abort), `fulfill` (return a \
2670              mock `status`/`headers`/`body`/`content_type`), or `delay` (proceed after `delay_ms`). \
2671              `times` limits how often it fires. Rules are page-scoped (cleared on reload).\n\
2672            - `list`: list active rules.\n\
2673            - `clear` (by `id`) / `clear_all`: remove rules.\n\
2674            - `matches`: log of intercepted requests.\n\
2675            Note: fetch supports all behaviors; XHR supports block/delay (fulfill is fetch-only). \
2676            Top-level navigation, sub-resource (img/css), and WebSocket traffic are not intercepted. \
2677            Tauri IPC: on the fetch transport (desktop, until a fallback) a rule matching an IPC URL \
2678            (http(s)://ipc.localhost/<cmd> or ipc://localhost/<cmd>) DOES change what the app's real \
2679            `invoke` gets. `fulfill` mocks the command (the Rust handler never runs; invoke resolves \
2680            only with a `Tauri-Response: ok` header, else rejects with the body); `delay` holds the \
2681            call back; `block` does NOT fail it (Tauri retries over postMessage and stays there until \
2682            reload). Mocked calls are tagged `mocked` in the IPC log and kept out of ghost detection, \
2683            command_catalog, coverage and timings; `add` returns an `ipc_note` when a rule can match \
2684            IPC. postMessage IPC (Android, after a fallback) is not affected. The `fault` tool \
2685            instead acts on commands you drive via `invoke_command`.",
2686        annotations(
2687            read_only_hint = false,
2688            destructive_hint = false,
2689            idempotent_hint = false,
2690            open_world_hint = false
2691        )
2692    )]
2693    async fn route(&self, Parameters(params): Parameters<RouteParams>) -> CallToolResult {
2694        match params.action {
2695            RouteAction::Add => {
2696                if !self.state.privacy.is_tool_enabled("route.add") {
2697                    return tool_disabled("route.add");
2698                }
2699                let Some(pattern) = &params.pattern else {
2700                    return missing_param("pattern", "add");
2701                };
2702                let behavior = params.behavior.unwrap_or(RouteBehavior::Fulfill);
2703                let match_type = params.match_type.unwrap_or(RouteMatchType::Substring);
2704                let mut rule = serde_json::json!({
2705                    "pattern": pattern,
2706                    "match_type": match_type.as_str(),
2707                    "action": behavior.as_str(),
2708                });
2709                if let Some(m) = &params.method {
2710                    rule["method"] = serde_json::json!(m);
2711                }
2712                if let Some(s) = params.status {
2713                    rule["status"] = serde_json::json!(s);
2714                }
2715                if let Some(st) = &params.status_text {
2716                    rule["status_text"] = serde_json::json!(st);
2717                }
2718                if let Some(h) = &params.headers {
2719                    rule["headers"] = h.clone();
2720                }
2721                if let Some(b) = &params.body {
2722                    // A JSON string body is passed through as-is; structured JSON
2723                    // is stringified so the bridge sends valid JSON text.
2724                    rule["body"] = match b {
2725                        serde_json::Value::String(s) => serde_json::json!(s),
2726                        other => serde_json::json!(other.to_string()),
2727                    };
2728                }
2729                if let Some(ct) = &params.content_type {
2730                    rule["content_type"] = serde_json::json!(ct);
2731                }
2732                if let Some(d) = params.delay_ms {
2733                    // A delayed request is held this long in the page; cap it like a `fault`
2734                    // delay rather than accept any u64.
2735                    if d > MAX_FAULT_DELAY_MS {
2736                        return tool_error_with_hint(
2737                            format!("delay_ms {d} exceeds the maximum of {MAX_FAULT_DELAY_MS} ms"),
2738                            RecoveryHint::CheckInput,
2739                        );
2740                    }
2741                    rule["delay_ms"] = serde_json::json!(d);
2742                }
2743                if let Some(t) = params.times {
2744                    rule["times"] = serde_json::json!(t);
2745                }
2746                let code = format!(
2747                    "return window.__VICTAURI__?.addRoute({})",
2748                    js_string(&rule.to_string())
2749                );
2750                self.eval_page_action(&code, params.webview_label.as_deref())
2751                    .await
2752            }
2753            RouteAction::List => {
2754                self.eval_bridge(
2755                    "return window.__VICTAURI__?.getRouteRules()",
2756                    params.webview_label.as_deref(),
2757                )
2758                .await
2759            }
2760            RouteAction::Clear => {
2761                let Some(id) = params.id else {
2762                    return missing_param("id", "clear");
2763                };
2764                let code = format!(
2765                    "return {}?.clearRoute({id})",
2766                    crate::js_bridge::agent_ops_js()
2767                );
2768                self.eval_bridge(&code, params.webview_label.as_deref())
2769                    .await
2770            }
2771            RouteAction::ClearAll => {
2772                let code = format!("return {}?.clearRoutes()", crate::js_bridge::agent_ops_js());
2773                self.eval_bridge(&code, params.webview_label.as_deref())
2774                    .await
2775            }
2776            RouteAction::Matches => {
2777                let limit = params.limit.unwrap_or(100);
2778                // A maximum of 0 entries is none — not "all" (the bridge's falsy limit).
2779                if limit == 0 {
2780                    return CallToolResult::success(vec![ContentBlock::text("[]")]);
2781                }
2782                let code = format!("return window.__VICTAURI__?.getRouteMatches({limit})");
2783                self.eval_bridge(&code, params.webview_label.as_deref())
2784                    .await
2785            }
2786        }
2787    }
2788
2789    #[tool(
2790        description = "Screencast / visual trace (no CDP). Captures the window at a fixed interval \
2791            into a ring buffer, forming a visual timeline that pairs with `recording` (events) and \
2792            `logs` (network/console). Actions:\n\
2793            - `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set \
2794              `with_events=true` to also start the event recorder. Hidden windows are skipped \
2795              (no frame), the buffer is capped at 256 MB, and an abandoned trace auto-stops \
2796              after 30 minutes.\n\
2797            - `stop`: stop and return a summary (frame count, duration, timestamps); also stops \
2798              the recording that `with_events` started (never one started separately).\n\
2799            - `status`: active flag + buffered frame count.\n\
2800            - `frames`: return captured frames as base64 PNGs, newest first up to a 25 MB \
2801              response (`limit` caps how many).",
2802        annotations(
2803            read_only_hint = false,
2804            destructive_hint = false,
2805            idempotent_hint = false,
2806            open_world_hint = false
2807        )
2808    )]
2809    async fn trace(&self, Parameters(params): Parameters<TraceParams>) -> CallToolResult {
2810        if !self.state.privacy.is_tool_enabled("trace")
2811            || !self.state.privacy.is_tool_enabled("screenshot")
2812        {
2813            return tool_disabled("trace");
2814        }
2815        match params.action {
2816            TraceAction::Start => {
2817                let interval = params.interval_ms.unwrap_or(500);
2818                let max_frames = params.max_frames.unwrap_or(60);
2819                let label = params.webview_label.clone();
2820                // A trace started over a running one supersedes it: end the recording the
2821                // previous trace started (if it is still the active one), or it would be left
2822                // running with no owner (and the per-second drain loop with it).
2823                let (generation, superseded) =
2824                    self.state
2825                        .screencast
2826                        .start(interval, max_frames, label.clone());
2827                if let Some(prev) = superseded {
2828                    let _ = self.state.recorder.stop_if_generation(prev);
2829                }
2830
2831                let mut events_started = false;
2832                if params.with_events.unwrap_or(false) {
2833                    let session_id = uuid::Uuid::new_v4().to_string();
2834                    let floor_ms = now_ms();
2835                    if let Ok(recording) = self.state.recorder.start_session(session_id) {
2836                        self.state.drain_watermarks.reset(floor_ms, recording);
2837                        // Only a recording THIS trace started is stopped with it — by recorder
2838                        // generation, so a recording started later (even under the same session
2839                        // id) is never stopped by us. If this trace was already stopped or
2840                        // superseded meanwhile, nothing would ever stop the recording: do it now.
2841                        if self
2842                            .state
2843                            .screencast
2844                            .set_owned_recording(generation, recording)
2845                        {
2846                            events_started = true;
2847                        } else {
2848                            let _ = self.state.recorder.stop_if_generation(recording);
2849                        }
2850                    }
2851                }
2852
2853                // Background capture task: snapshot the window each interval until the
2854                // screencast is stopped, superseded by a newer start, or hits the max duration.
2855                // The guard ends the trace (and its recording) however the task exits — the
2856                // max-duration break, or a panic.
2857                let handler = self.clone();
2858                let screencast = self.state.screencast.clone();
2859                let guard = crate::screencast::CaptureTaskGuard {
2860                    screencast: Arc::clone(&screencast),
2861                    recorder: self.state.recorder.clone(),
2862                    generation,
2863                };
2864                tokio::spawn(async move {
2865                    let _guard = guard;
2866                    let t0 = std::time::Instant::now();
2867                    // An abandoned trace must not capture (and burn CPU) forever: past the max
2868                    // duration the loop ends and the guard stops it (only if no newer trace has
2869                    // started in the meantime).
2870                    while screencast.is_current(generation) && t0.elapsed() < MAX_TRACE_DURATION {
2871                        // Same visible-target rule as `screenshot`: a hidden window yields
2872                        // stale or another window's pixels, so skip the frame instead.
2873                        if let Ok(target) = handler.resolve_visible_capture_target(label.as_deref())
2874                            && let Ok(handle) = handler.bridge.get_native_handle(Some(&target))
2875                            && let Ok(png) = crate::screenshot::capture_window(handle).await
2876                        {
2877                            use base64::Engine;
2878                            let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2879                            #[allow(clippy::cast_possible_truncation)]
2880                            screencast.push_frame_if_current(
2881                                generation,
2882                                t0.elapsed().as_millis() as u64,
2883                                b64,
2884                            );
2885                        }
2886                        tokio::time::sleep(std::time::Duration::from_millis(
2887                            screencast.interval_ms(),
2888                        ))
2889                        .await;
2890                    }
2891                });
2892
2893                json_result(&serde_json::json!({
2894                    "started": true,
2895                    "interval_ms": self.state.screencast.interval_ms(),
2896                    "max_frames": max_frames.clamp(1, 600),
2897                    "with_events": events_started,
2898                }))
2899            }
2900            TraceAction::Stop => {
2901                let (frame_count, owned) = self.state.screencast.stop();
2902                let timestamps = self.state.screencast.frame_timestamps();
2903                let duration_ms = timestamps.last().copied().unwrap_or(0);
2904                // Stop the recording `with_events` started, so the recorder (and the
2905                // per-second drain loop it enables) does not outlive the trace. The session
2906                // stays readable via recording get_events/export (last stopped session).
2907                let event_count = match owned {
2908                    Some(owned) => self
2909                        .state
2910                        .recorder
2911                        .stop_if_generation(owned)
2912                        .map_or(0, |session| session.events.len()),
2913                    None => self.state.recorder.event_count(),
2914                };
2915                json_result(&serde_json::json!({
2916                    "stopped": true,
2917                    "frame_count": frame_count,
2918                    "duration_ms": duration_ms,
2919                    "frame_timestamps_ms": timestamps,
2920                    "recorded_event_count": event_count,
2921                    "hint": "use action=frames to retrieve PNGs; pair with recording/get_events and logs for a full bundle",
2922                }))
2923            }
2924            TraceAction::Status => json_result(&serde_json::json!({
2925                "active": self.state.screencast.is_active(),
2926                "frame_count": self.state.screencast.frame_count(),
2927                "interval_ms": self.state.screencast.interval_ms(),
2928            })),
2929            TraceAction::Frames => {
2930                let limit = params.limit.unwrap_or(0);
2931                let frames = self.state.screencast.frames(limit);
2932                if frames.is_empty() {
2933                    return json_result(&serde_json::json!({ "frames": 0 }));
2934                }
2935                // Bound the response: keep the NEWEST frames that fit the byte budget.
2936                let available = frames.len();
2937                let mut budget = MAX_TRACE_FRAMES_RESPONSE_BYTES;
2938                let mut kept: Vec<ContentBlock> = Vec::new();
2939                for f in frames.into_iter().rev() {
2940                    if f.data_b64.len() > budget && !kept.is_empty() {
2941                        break;
2942                    }
2943                    budget = budget.saturating_sub(f.data_b64.len());
2944                    kept.push(ContentBlock::image(f.data_b64, "image/png"));
2945                }
2946                kept.reverse();
2947                if kept.len() < available {
2948                    kept.insert(
2949                        0,
2950                        ContentBlock::text(format!(
2951                            "returned the newest {} of {available} frames (response capped at \
2952                             {} MB); pass a smaller `limit` to page",
2953                            kept.len(),
2954                            MAX_TRACE_FRAMES_RESPONSE_BYTES / (1024 * 1024)
2955                        )),
2956                    );
2957                }
2958                CallToolResult::success(kept)
2959            }
2960        }
2961    }
2962
2963    #[tool(
2964        description = "Animation introspection (no CDP). Reads the Web Animations API to reveal what \
2965            the webview's animation engine is actually running — duration, delay, easing, iterations, \
2966            keyframes, current progress, and the animating element. Standard DOM, so it works \
2967            identically on WebView2/WKWebView/WebKitGTK. Actions:\n\
2968            - `list`: return all running CSS animations/transitions (optionally scoped by `selector`), \
2969              each with declared `timing`, `computed` progress, `keyframes`, and `target`.\n\
2970            - `scrub`: deterministically pause the target's animation and seek it to `points` \
2971              evenly-spaced steps (default 20), returning the exact geometry curve (rect + transform \
2972              + opacity per step). With `capture=true`, also returns a single contact-sheet filmstrip \
2973              PNG (one image of the whole arc) plus a `manifest` mapping each cell to its progress/time. \
2974              Frozen frames are jank-free, so this beats real-time capture for fast sweeps. CSS-driven \
2975              animations only (JS/rAF animations are not seekable — use `list`/`sample`).\n\
2976            - `sample`: real-time motion recorder. `record=true` arms a requestAnimationFrame watcher \
2977              on `selector` (or the first animating element); then trigger the animation; then call \
2978              with `record=false` to read the measured per-frame curve plus jank stats (dropped frames, \
2979              max frame gap) and declared-vs-measured duration. Works for ANY animation including \
2980              JS/rAF-driven ones. `clear=true` resets recorded sessions.\n\
2981            NOTE: an animation only appears while it is running or pending — trigger it (e.g. show the \
2982            notification) just before calling `list`/`scrub`, or arm `sample` before triggering.",
2983        annotations(
2984            read_only_hint = false,
2985            destructive_hint = false,
2986            idempotent_hint = false,
2987            open_world_hint = false
2988        )
2989    )]
2990    async fn animation(&self, Parameters(params): Parameters<AnimationParams>) -> CallToolResult {
2991        if !self.state.privacy.is_tool_enabled("animation") {
2992            return tool_disabled("animation");
2993        }
2994        match params.action {
2995            AnimationAction::List => {
2996                let sel = params
2997                    .selector
2998                    .as_deref()
2999                    .map_or_else(|| "null".to_string(), js_string);
3000                let code = format!(
3001                    "return window.__VICTAURI__ && window.__VICTAURI__.listAnimations({sel})"
3002                );
3003                match self
3004                    .eval_with_return(&code, params.webview_label.as_deref())
3005                    .await
3006                {
3007                    Ok(result_str) => {
3008                        match serde_json::from_str::<serde_json::Value>(&result_str) {
3009                            Ok(v) => json_result(&v),
3010                            Err(_) => CallToolResult::success(vec![ContentBlock::text(result_str)]),
3011                        }
3012                    }
3013                    Err(e) => tool_error(format!("animation list failed: {e}")),
3014                }
3015            }
3016            AnimationAction::Scrub => {
3017                // `capture=true` takes native window screenshots (a filmstrip), so it needs the
3018                // `screenshot` tool as well — like `trace` — or an operator who disabled
3019                // screenshots would still get pixels through here. Refused before anything runs.
3020                if params.capture.unwrap_or(false)
3021                    && !self.state.privacy.is_tool_enabled("screenshot")
3022                {
3023                    return tool_error_with_hint(
3024                        "animation scrub with capture=true takes native window screenshots, but \
3025                         tool 'screenshot' is disabled by privacy configuration — call scrub \
3026                         without `capture` to get the geometry curve only",
3027                        RecoveryHint::ReportToUser,
3028                    );
3029                }
3030                self.animation_scrub(params).await
3031            }
3032            AnimationAction::Sample => {
3033                let label = params.webview_label.as_deref();
3034                let sel = params
3035                    .selector
3036                    .as_deref()
3037                    .map_or_else(|| "null".to_string(), js_string);
3038                let code = if params.record.unwrap_or(false) {
3039                    crate::js_bridge::agent_op_call_js(&format!("installSweepRecorder({sel})"))
3040                } else {
3041                    let clear = params.clear.unwrap_or(false);
3042                    crate::js_bridge::agent_op_call_js(&format!("readSweep({clear})"))
3043                };
3044                match self.eval_with_return(&code, label).await {
3045                    Ok(result_str) => {
3046                        match serde_json::from_str::<serde_json::Value>(&result_str) {
3047                            Ok(v) => json_result(&v),
3048                            Err(_) => CallToolResult::success(vec![ContentBlock::text(result_str)]),
3049                        }
3050                    }
3051                    Err(e) => tool_error(format!("animation sample failed: {e}")),
3052                }
3053            }
3054        }
3055    }
3056
3057    /// Deterministic pause-seek-capture loop for `animation scrub`. Split out to
3058    /// keep the `#[tool]` method readable.
3059    async fn animation_scrub(&self, params: AnimationParams) -> CallToolResult {
3060        let label = params.webview_label.as_deref();
3061        let sel = params
3062            .selector
3063            .as_deref()
3064            .map_or_else(|| "null".to_string(), js_string);
3065
3066        // 1. Prepare: pause the target's animations, learn the timeline length.
3067        let prep_code = crate::js_bridge::agent_op_call_js(&format!("scrubPrepare({sel})"));
3068        let prep_v = match self.eval_with_return(&prep_code, label).await {
3069            Ok(s) => {
3070                serde_json::from_str::<serde_json::Value>(&s).unwrap_or(serde_json::Value::Null)
3071            }
3072            Err(e) => return tool_error(format!("scrub prepare failed: {e}")),
3073        };
3074        if prep_v.get("prepared").and_then(serde_json::Value::as_bool) != Some(true) {
3075            // Surface the helpful error/info object (no target, JS-driven, etc.).
3076            return json_result(&prep_v);
3077        }
3078
3079        let points = params.points.unwrap_or(20).clamp(2, 120);
3080        let capture = params.capture.unwrap_or(false);
3081        let cols_for = |n: usize| {
3082            params
3083                .cols
3084                .unwrap_or_else(|| crate::filmstrip::default_cols(n))
3085        };
3086        let resume = params.restore.unwrap_or(true);
3087        let restore_code = crate::js_bridge::agent_op_call_js(&format!("scrubRestore({resume})"));
3088        let mut curve: Vec<serde_json::Value> = Vec::with_capacity(points);
3089        let mut frames: Vec<crate::filmstrip::Frame> = Vec::new();
3090        let mut manifest: Vec<serde_json::Value> = Vec::new();
3091        // The first reason a frame could not be captured (reported, never swallowed).
3092        let mut capture_error: Option<String> = None;
3093
3094        // 2. Seek to each evenly-spaced point; capture the frozen frame if asked.
3095        for i in 0..points {
3096            #[allow(clippy::cast_precision_loss)]
3097            let progress = i as f64 / (points - 1) as f64;
3098            let seek_code = crate::js_bridge::agent_op_call_js(&format!("scrubSeek({progress})"));
3099            match self.eval_with_return(&seek_code, label).await {
3100                Ok(s) => {
3101                    let v = serde_json::from_str::<serde_json::Value>(&s)
3102                        .unwrap_or(serde_json::Value::Null);
3103                    if capture {
3104                        match self.capture_scrub_frame(label).await {
3105                            Ok(frame) => {
3106                                // Before holding more frames: would the finished sheet be
3107                                // composable at all? (Raw RGBA frames accumulate until then.)
3108                                if frames.is_empty()
3109                                    && let Err(e) = crate::filmstrip::check_sheet(
3110                                        frame.w,
3111                                        frame.h,
3112                                        points,
3113                                        cols_for(points),
3114                                        FILMSTRIP_GAP,
3115                                    )
3116                                {
3117                                    let _ = self.eval_with_return(&restore_code, label).await;
3118                                    let fitting = (2..points).rev().find(|&n| {
3119                                        crate::filmstrip::check_sheet(
3120                                            frame.w,
3121                                            frame.h,
3122                                            n,
3123                                            cols_for(n),
3124                                            FILMSTRIP_GAP,
3125                                        )
3126                                        .is_ok()
3127                                    });
3128                                    let advice = fitting.map_or_else(
3129                                        || "call scrub without `capture` for the geometry curve                                             (or shrink the window)"
3130                                            .to_string(),
3131                                        |n| format!("use `points` <= {n} (or more `cols`)"),
3132                                    );
3133                                    return tool_error(format!(
3134                                        "animation scrub capture refused before capturing: {e};                                          {advice}"
3135                                    ));
3136                                }
3137                                manifest.push(serde_json::json!({
3138                                    "cell": frames.len(),
3139                                    "progress": progress,
3140                                    "t": v.get("t").cloned().unwrap_or(serde_json::Value::Null),
3141                                }));
3142                                frames.push(frame);
3143                            }
3144                            Err(e) => {
3145                                capture_error.get_or_insert(e);
3146                            }
3147                        }
3148                    }
3149                    curve.push(v);
3150                }
3151                Err(e) => curve.push(serde_json::json!({ "progress": progress, "error": e })),
3152            }
3153        }
3154
3155        // 3. Restore (resume) or leave paused.
3156        let _ = self.eval_with_return(&restore_code, label).await;
3157
3158        let mut meta = serde_json::json!({
3159            "scrubbed": true,
3160            "points": points,
3161            "duration_ms": prep_v.get("duration").cloned().unwrap_or(serde_json::Value::Null),
3162            "anim_count": prep_v.get("anim_count").cloned().unwrap_or(serde_json::Value::Null),
3163            "target": prep_v.get("target").cloned().unwrap_or(serde_json::Value::Null),
3164            // True only when a filmstrip is returned with this result.
3165            "captured": false,
3166            "curve": curve,
3167        });
3168        if let Some(e) = &capture_error {
3169            meta["capture_error"] = serde_json::json!(e);
3170        }
3171
3172        // 4. Compose the filmstrip if we captured frames.
3173        if capture && !frames.is_empty() {
3174            let cols = cols_for(frames.len());
3175            let (rgba, w, h) =
3176                match crate::filmstrip::compose(&frames, cols, FILMSTRIP_GAP, [20, 20, 20, 255]) {
3177                    Ok(sheet) => sheet,
3178                    Err(e) => return tool_error(format!("filmstrip compose failed: {e}")),
3179                };
3180            drop(frames);
3181            match crate::screenshot::encode_png(w, h, &rgba) {
3182                Ok(png) => {
3183                    use base64::Engine;
3184                    let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
3185                    meta["captured"] = serde_json::json!(true);
3186                    meta["filmstrip"] = serde_json::json!({
3187                        "cols": cols,
3188                        "frame_count": manifest.len(),
3189                        "width": w,
3190                        "height": h,
3191                        "manifest": manifest,
3192                    });
3193                    return CallToolResult::success(vec![
3194                        ContentBlock::image(b64, "image/png"),
3195                        ContentBlock::text(meta.to_string()),
3196                    ]);
3197                }
3198                Err(e) => return tool_error(format!("filmstrip encode failed: {e}")),
3199            }
3200        }
3201
3202        json_result(&meta)
3203    }
3204
3205    /// One native capture of the (frozen) scrub target window, as a filmstrip frame.
3206    async fn capture_scrub_frame(
3207        &self,
3208        label: Option<&str>,
3209    ) -> Result<crate::filmstrip::Frame, String> {
3210        let handle = self
3211            .bridge
3212            .get_native_handle(label)
3213            .map_err(|e| format!("no native window handle: {e}"))?;
3214        let (rgba, w, h) = crate::screenshot::capture_window_raw(handle)
3215            .await
3216            .map_err(|e| format!("window capture failed: {e}"))?;
3217        crate::filmstrip::Frame::new(rgba, w, h)
3218            .ok_or_else(|| format!("window capture returned a malformed {w}x{h} frame"))
3219    }
3220
3221    #[tool(
3222        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; entries captured on Tauri's postMessage IPC fallback carry transport:'post_message', see get_diagnostics ipc_capture), navigation (URL change history), dialogs (alert/confirm/prompt events), events (combined event stream), slow_ipc (find slow IPC calls), clear (DELETES the captured IPC + network logs — use for per-test isolation).",
3223        annotations(
3224            read_only_hint = false,
3225            destructive_hint = true,
3226            idempotent_hint = true,
3227            open_world_hint = false
3228        )
3229    )]
3230    async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
3231        // `limit` is the maximum number of entries to return, so 0 returns none (R5-JS5). The
3232        // page is not asked at all: `.slice(-0)` and the bridge's "falsy limit = everything"
3233        // used to turn it into EVERY entry, bodies included.
3234        if params.limit == Some(0)
3235            && matches!(
3236                params.action,
3237                LogsAction::Console
3238                    | LogsAction::Network
3239                    | LogsAction::Ipc
3240                    | LogsAction::Navigation
3241                    | LogsAction::Dialogs
3242                    | LogsAction::Events
3243            )
3244        {
3245            return CallToolResult::success(vec![ContentBlock::text("[]")]);
3246        }
3247        match params.action {
3248            LogsAction::Console => {
3249                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
3250                let base = if since_arg.is_empty() {
3251                    "window.__VICTAURI__?.getConsoleLogs()".to_string()
3252                } else {
3253                    format!("window.__VICTAURI__?.getConsoleLogs({since_arg})")
3254                };
3255                let code = if let Some(limit) = params.limit {
3256                    format!("return ({base} || []).slice(-{limit})")
3257                } else {
3258                    format!("return {base}")
3259                };
3260                self.eval_bridge(&code, params.webview_label.as_deref())
3261                    .await
3262            }
3263            LogsAction::Network => {
3264                let filter_arg = params
3265                    .filter
3266                    .as_ref()
3267                    .map_or_else(|| "null".to_string(), |f| js_string(f));
3268                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
3269                let source = format!("window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit})");
3270                let code = trimmed_log_js(&source, limit);
3271                self.eval_bridge(&code, params.webview_label.as_deref())
3272                    .await
3273            }
3274            LogsAction::Ipc => {
3275                let wait = params.wait_for_capture.unwrap_or(false);
3276                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
3277                if wait {
3278                    let inner =
3279                        trimmed_log_js(&format!("window.__VICTAURI__.getIpcLog({limit})"), limit);
3280                    let code = format!(
3281                        r"return (async function() {{
3282                            await window.__VICTAURI__.waitForIpcComplete(500);
3283                            return (function() {{ {inner} }})();
3284                        }})()"
3285                    );
3286                    let timeout = std::time::Duration::from_millis(5000);
3287                    match self
3288                        .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
3289                        .await
3290                    {
3291                        Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
3292                        Err(e) => tool_error(e),
3293                    }
3294                } else {
3295                    let code =
3296                        trimmed_log_js(&format!("window.__VICTAURI__?.getIpcLog({limit})"), limit);
3297                    self.eval_bridge(&code, params.webview_label.as_deref())
3298                        .await
3299                }
3300            }
3301            LogsAction::Navigation => {
3302                let code = if let Some(limit) = params.limit {
3303                    format!(
3304                        "return (window.__VICTAURI__?.getNavigationLog() || []).slice(-{limit})"
3305                    )
3306                } else {
3307                    "return window.__VICTAURI__?.getNavigationLog()".to_string()
3308                };
3309                self.eval_bridge(&code, params.webview_label.as_deref())
3310                    .await
3311            }
3312            LogsAction::Dialogs => {
3313                let code = if let Some(limit) = params.limit {
3314                    format!("return (window.__VICTAURI__?.getDialogLog() || []).slice(-{limit})")
3315                } else {
3316                    "return window.__VICTAURI__?.getDialogLog()".to_string()
3317                };
3318                self.eval_bridge(&code, params.webview_label.as_deref())
3319                    .await
3320            }
3321            LogsAction::Events => {
3322                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
3323                let base = if since_arg.is_empty() {
3324                    "window.__VICTAURI__?.getEventStream()".to_string()
3325                } else {
3326                    format!("window.__VICTAURI__?.getEventStream({since_arg})")
3327                };
3328                let code = if let Some(limit) = params.limit {
3329                    format!("return ({base} || []).slice(-{limit})")
3330                } else {
3331                    format!("return {base}")
3332                };
3333                self.eval_bridge(&code, params.webview_label.as_deref())
3334                    .await
3335            }
3336            LogsAction::SlowIpc => {
3337                let Some(threshold) = params.threshold_ms else {
3338                    return missing_param("threshold_ms", "slow_ipc");
3339                };
3340                let limit = params.limit.unwrap_or(20);
3341                let code = slow_ipc_js(threshold, limit);
3342                self.eval_bridge(&code, params.webview_label.as_deref())
3343                    .await
3344            }
3345            LogsAction::Clear => {
3346                // Clearing the IPC/network logs erases captured evidence — a
3347                // mutation of observable state — so it is gated separately and
3348                // excluded from the read-only Observe profile (red-team P1).
3349                if !self.state.privacy.is_tool_enabled("logs.clear") {
3350                    return tool_disabled("logs.clear");
3351                }
3352                let code = format!(
3353                    "return (function(){{ var b = {}; if (!b) return {{ ok:false, error:'bridge unavailable' }}; b.clearIpcLog(); b.clearNetworkLog(); return {{ ok:true, cleared:['ipc','network'] }}; }})()",
3354                    crate::js_bridge::agent_ops_js()
3355                );
3356                self.eval_bridge(&code, params.webview_label.as_deref())
3357                    .await
3358            }
3359        }
3360    }
3361
3362    // ── Backend Introspection ────────────────────────────────────────────────
3363
3364    #[tool(
3365        description = "Deep backend introspection — command profiling, IPC contract testing, \
3366            coverage, startup timing, capability auditing, database diagnostics, process \
3367            enumeration, and event bus monitoring. \
3368            These features exploit Victauri's position inside the Rust process.\n\n\
3369            Actions:\n\
3370            - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n\
3371            - `coverage`: Which registered commands have been called during this session.\n\
3372            - `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\
3373            - `contract_record`: Record a command's response shape as a baseline (requires `command`).\n\
3374            - `contract_check`: Check all recorded contracts for schema drift.\n\
3375            - `contract_list`: List all recorded contract baselines.\n\
3376            - `contract_clear`: Clear all recorded contract baselines.\n\
3377            - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
3378            - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
3379            - `db_health`: Read-only SQLite diagnostics: journal mode, page stats, freelist, \
3380              per-table row counts, and SQLite `quick_check` — each phase budgeted; a phase that \
3381              runs out is reported (`row_counts_complete`, `integrity_check: \"not completed…\"`) \
3382              instead of failing.\n\
3383            - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
3384            - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
3385            - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
3386            - `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\
3387            - `event_bus_clear`: Clear the event bus capture buffer.",
3388        annotations(
3389            read_only_hint = false,
3390            destructive_hint = true,
3391            idempotent_hint = false,
3392            open_world_hint = false
3393        )
3394    )]
3395    async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
3396        if !self.state.privacy.is_tool_enabled("introspect") {
3397            return tool_disabled("introspect");
3398        }
3399
3400        match params.action {
3401            IntrospectAction::CommandTimings => {
3402                let mut stats = self.state.command_timings.all_stats();
3403                let driven_count = stats.len();
3404                if let Some(threshold) = params.slow_threshold_ms {
3405                    stats.retain(|s| s.avg_ms >= threshold);
3406                }
3407
3408                // Real frontend traffic: derive per-command latency from the live IPC
3409                // log so the profiler is not blind to commands the app itself drives.
3410                // `command_timings` (above) only records Victauri-driven invoke_command
3411                // calls — on a running app that counter is typically 0 while the app
3412                // makes hundreds of real calls. The IPC log captures those with
3413                // duration; the name+duration projection stays under the eval cap.
3414                let code = ipc_timing_projection_js(None);
3415                let mut ipc_traffic = match self
3416                    .eval_with_return(&code, params.webview_label.as_deref())
3417                    .await
3418                {
3419                    Ok(json_str) => page_json::parse_page_json::<Vec<serde_json::Value>>(&json_str)
3420                        .map(|entries| ipc_timing_stats(&entries))
3421                        .unwrap_or_default(),
3422                    Err(_) => Vec::new(),
3423                };
3424                if let Some(threshold) = params.slow_threshold_ms {
3425                    ipc_traffic.retain(|s| {
3426                        s.get("avg_ms")
3427                            .and_then(serde_json::Value::as_f64)
3428                            .is_some_and(|a| a >= threshold)
3429                    });
3430                }
3431
3432                let ipc_capture = self
3433                    .ipc_capture_status(params.webview_label.as_deref())
3434                    .await;
3435                let result = serde_json::json!({
3436                    "commands": stats,
3437                    "total_commands_profiled": driven_count,
3438                    "ipc_capture": ipc_capture,
3439                    // The store stops tracking NEW command names at its cap; say so rather
3440                    // than let a missing command look like one that never ran.
3441                    "saturated": driven_count >= COMMAND_TIMINGS_CAP,
3442                    "ipc_traffic": ipc_traffic,
3443                    "ipc_commands_observed": ipc_traffic.len(),
3444                    "slow_threshold_ms": params.slow_threshold_ms,
3445                    "note": "`commands` profiles ONLY commands you drove through Victauri's \
3446                             invoke_command tool (often empty on a live app). `ipc_traffic` \
3447                             profiles the app's REAL frontend IPC, derived from the live IPC \
3448                             log (per-command call_count + min/max/avg/p95 latency) — that is \
3449                             the one reflecting actual usage.",
3450                });
3451                json_result(&result)
3452            }
3453            IntrospectAction::Coverage => {
3454                let registered: Vec<String> = self
3455                    .state
3456                    .registry
3457                    .list()
3458                    .iter()
3459                    .map(|c| c.name.clone())
3460                    .collect();
3461
3462                // Project to command NAMES ONLY. The previous full `getIpcLog()` carried
3463                // request/response bodies and blew the eval result cap on busy apps,
3464                // silently returning an empty set and reporting "0 invoked" despite live
3465                // traffic. This is the same name projection ghost detection uses.
3466                let code = ghost_ipc_projection_js(None);
3467                let (invoked, ipc_calls_observed): (std::collections::HashSet<String>, usize) =
3468                    match self
3469                        .eval_with_return(&code, params.webview_label.as_deref())
3470                        .await
3471                    {
3472                        Ok(json_str) => {
3473                            match page_json::parse_page_json::<Vec<String>>(&json_str) {
3474                                Ok(names) => {
3475                                    let count = names.len();
3476                                    (names.into_iter().collect(), count)
3477                                }
3478                                Err(_) => (std::collections::HashSet::new(), 0),
3479                            }
3480                        }
3481                        Err(_) => (std::collections::HashSet::new(), 0),
3482                    };
3483
3484                let uncovered: Vec<&String> = registered
3485                    .iter()
3486                    .filter(|cmd| !invoked.contains(cmd.as_str()))
3487                    .collect();
3488
3489                let coverage_pct = if registered.is_empty() {
3490                    100.0
3491                } else {
3492                    let covered = registered.len() - uncovered.len();
3493                    (covered as f64 / registered.len() as f64) * 100.0
3494                };
3495
3496                let note = if registered.is_empty() {
3497                    Some(
3498                        "The introspection registry is empty (the app does not use \
3499                         #[inspectable]/register_command_names), so coverage_pct is a \
3500                         placeholder 100%. `invoked_not_registered` still lists the real \
3501                         commands seen on the live IPC log — use it to inventory actual \
3502                         traffic.",
3503                    )
3504                } else if ipc_calls_observed == 0 {
3505                    Some(
3506                        "No IPC calls were observed on the live log. If the app is actively \
3507                         making calls, confirm the target webview and check `ipc_capture`: \
3508                         a `capture` other than `full` means the IPC picture is partial.",
3509                    )
3510                } else {
3511                    None
3512                };
3513                let ipc_capture = self
3514                    .ipc_capture_status(params.webview_label.as_deref())
3515                    .await;
3516
3517                let result = serde_json::json!({
3518                    "ipc_capture": ipc_capture,
3519                    "registered_commands": registered.len(),
3520                    "invoked_commands": invoked.len(),
3521                    "ipc_calls_observed": ipc_calls_observed,
3522                    "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
3523                    "uncovered": uncovered,
3524                    "invoked_not_registered": invoked.iter()
3525                        .filter(|cmd| !registered.contains(cmd))
3526                        .collect::<Vec<_>>(),
3527                    "note": note,
3528                });
3529                json_result(&result)
3530            }
3531            IntrospectAction::CommandCatalog => {
3532                // Mine the live IPC log for per-command argument + result SHAPES (inferred
3533                // in JS, bodies never shipped — so it stays under the eval cap on busy apps)
3534                // and merge with the #[inspectable] registry. This is the answer to a real
3535                // live gap: an app without #[inspectable] (e.g. 4DA — 379 commands, every
3536                // registry field null) gives an agent command NAMES but no call/return
3537                // schema; the IPC log holds the actual shapes, so we project them out.
3538                let code = ipc_catalog_projection_js();
3539                let ipc_entries: Vec<serde_json::Value> = match self
3540                    .eval_with_return(&code, params.webview_label.as_deref())
3541                    .await
3542                {
3543                    Ok(json_str) => page_json::parse_page_json(&json_str).unwrap_or_default(),
3544                    Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
3545                };
3546
3547                let registry = self.state.registry.list();
3548                let catalog = merge_command_catalog(&ipc_entries, &registry);
3549                let observed = catalog
3550                    .iter()
3551                    .filter(|c| {
3552                        c.get("observed")
3553                            .and_then(serde_json::Value::as_bool)
3554                            .unwrap_or(false)
3555                    })
3556                    .count();
3557                let ipc_capture = self
3558                    .ipc_capture_status(params.webview_label.as_deref())
3559                    .await;
3560
3561                let result = serde_json::json!({
3562                    "ipc_capture": ipc_capture,
3563                    "catalog": catalog,
3564                    "observed_count": observed,
3565                    "registered_count": registry.len(),
3566                    "total": catalog.len(),
3567                    "note": "`arg_shape`/`result_shape` are STRUCTURES inferred from the live \
3568                             IPC log (keys + value types, not values) — how to CALL each command \
3569                             and what it RETURNS, even for apps that don't use #[inspectable]. \
3570                             `observed:false` means the command is in the registry but hasn't \
3571                             been seen on the wire this session (drive the app's UI to populate \
3572                             its shape). `declared_*` fields, when present, come from \
3573                             #[inspectable] and are authoritative over the inferred shape.",
3574                });
3575                json_result(&result)
3576            }
3577            IntrospectAction::ContractRecord => {
3578                let Some(command) = params.command else {
3579                    return missing_param("command", "contract_record");
3580                };
3581                // contract_record invokes the command with caller-supplied args, so
3582                // it must honour the same allow/blocklist as invoke_command (audit #30).
3583                if !self.state.privacy.is_invoke_allowed(&command)
3584                    || !self.state.privacy.is_command_allowed(&command)
3585                {
3586                    return tool_error(format!(
3587                        "command '{command}' is blocked by privacy configuration"
3588                    ));
3589                }
3590                let args_json = params.args.unwrap_or(serde_json::json!({}));
3591                let args_str =
3592                    serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
3593                let code = format!(
3594                    "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
3595                    js_string(&command)
3596                );
3597                match self
3598                    .eval_with_return(&code, params.webview_label.as_deref())
3599                    .await
3600                {
3601                    Ok(result_str) => {
3602                        let value: serde_json::Value = serde_json::from_str(&result_str)
3603                            .unwrap_or(serde_json::Value::String(result_str.clone()));
3604                        let shape = crate::introspection::JsonShape::from_value(&value);
3605                        let sample = if result_str.len() > 4096 {
3606                            format!(
3607                                "{}...(truncated)",
3608                                truncate_at_char_boundary(&result_str, 4096)
3609                            )
3610                        } else {
3611                            result_str
3612                        };
3613                        let baseline = crate::introspection::ContractBaseline {
3614                            command: command.clone(),
3615                            args: args_json,
3616                            shape: shape.clone(),
3617                            sample,
3618                            recorded_at: chrono_now(),
3619                        };
3620                        self.state.contract_store.record(baseline);
3621                        let result = serde_json::json!({
3622                            "recorded": true,
3623                            "command": command,
3624                            "shape_type": shape.type_name(),
3625                        });
3626                        json_result(&result)
3627                    }
3628                    Err(e) => tool_error(format!(
3629                        "failed to invoke '{command}' for contract recording: {e}"
3630                    )),
3631                }
3632            }
3633            IntrospectAction::ContractCheck => {
3634                let baselines = self.state.contract_store.all();
3635                if baselines.is_empty() {
3636                    return json_result(&serde_json::json!({
3637                        "checked": 0,
3638                        "message": "no contract baselines recorded — use contract_record first",
3639                    }));
3640                }
3641                let mut results = Vec::new();
3642                for baseline in &baselines {
3643                    // Re-checking a baseline re-invokes the command; honour the
3644                    // allow/blocklist in case it changed since recording (audit #30).
3645                    if !self.state.privacy.is_invoke_allowed(&baseline.command)
3646                        || !self.state.privacy.is_command_allowed(&baseline.command)
3647                    {
3648                        continue;
3649                    }
3650                    let args_str =
3651                        serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
3652                    let code = format!(
3653                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
3654                        js_string(&baseline.command)
3655                    );
3656                    match self
3657                        .eval_with_return(&code, params.webview_label.as_deref())
3658                        .await
3659                    {
3660                        Ok(result_str) => {
3661                            let value: serde_json::Value = serde_json::from_str(&result_str)
3662                                .unwrap_or(serde_json::Value::String(result_str));
3663                            let current_shape = crate::introspection::JsonShape::from_value(&value);
3664                            let drift = crate::introspection::diff_shapes(
3665                                &baseline.shape,
3666                                &current_shape,
3667                                &baseline.command,
3668                            );
3669                            results.push(drift);
3670                        }
3671                        Err(e) => {
3672                            results.push(crate::introspection::ContractDrift {
3673                                command: baseline.command.clone(),
3674                                new_fields: Vec::new(),
3675                                removed_fields: Vec::new(),
3676                                type_changes: Vec::new(),
3677                                shape_matches: false,
3678                            });
3679                            tracing::warn!(
3680                                command = %baseline.command,
3681                                error = %e,
3682                                "contract check invocation failed"
3683                            );
3684                        }
3685                    }
3686                }
3687                let passing = results.iter().filter(|r| r.shape_matches).count();
3688                let result = serde_json::json!({
3689                    "checked": results.len(),
3690                    "passing": passing,
3691                    "failing": results.len() - passing,
3692                    "contracts": results,
3693                });
3694                json_result(&result)
3695            }
3696            IntrospectAction::ContractList => {
3697                let baselines = self.state.contract_store.all();
3698                let result = serde_json::json!({
3699                    "count": baselines.len(),
3700                    "baselines": baselines.iter().map(|b| serde_json::json!({
3701                        "command": b.command,
3702                        "shape_type": b.shape.type_name(),
3703                        "recorded_at": b.recorded_at,
3704                    })).collect::<Vec<_>>(),
3705                });
3706                json_result(&result)
3707            }
3708            IntrospectAction::ContractClear => {
3709                let cleared = self.state.contract_store.clear();
3710                json_result(&serde_json::json!({
3711                    "cleared": cleared,
3712                }))
3713            }
3714            IntrospectAction::StartupTiming => {
3715                let phases = self.state.startup_timeline.report();
3716                let result = serde_json::json!({
3717                    "phases": phases,
3718                    "total_ms": self.state.startup_timeline.total_ms(),
3719                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
3720                });
3721                json_result(&result)
3722            }
3723            IntrospectAction::Capabilities => {
3724                let config = self.bridge.tauri_config();
3725                // A busy UI is reported as such, never as "no live windows".
3726                let (live_windows, live_windows_error) = match self.bridge.try_list_window_labels()
3727                {
3728                    Ok(labels) => (Some(labels), None),
3729                    Err(e) => (None, Some(ui_busy(&e))),
3730                };
3731
3732                let result = serde_json::json!({
3733                    "app": {
3734                        "identifier": config.get("identifier"),
3735                        "product_name": config.get("product_name"),
3736                        "version": config.get("version"),
3737                    },
3738                    "security": config.get("security"),
3739                    "configured_windows": config.get("windows"),
3740                    "live_windows": live_windows,
3741                    "live_windows_error": live_windows_error,
3742                    "configured_plugins": config.get("plugins"),
3743                    "victauri": {
3744                        "registered_commands": self.state.registry.list().len(),
3745                        "redaction_enabled": self.state.privacy.redaction_enabled,
3746                        "privacy_profile": format!("{:?}", self.state.privacy.profile),
3747                        "disabled_tools": &self.state.privacy.disabled_tools,
3748                    },
3749                });
3750                json_result(&result)
3751            }
3752            #[allow(unused_variables)]
3753            IntrospectAction::DbHealth => {
3754                #[cfg(feature = "sqlite")]
3755                {
3756                    let db_path = params.db_path.clone();
3757                    match self.run_db_health(db_path.as_deref()).await {
3758                        Ok(health) => json_result(&health),
3759                        Err(e) => tool_error(format!("db_health failed: {e}")),
3760                    }
3761                }
3762                #[cfg(not(feature = "sqlite"))]
3763                {
3764                    tool_error("SQLite support not compiled in — enable the `sqlite` feature")
3765                }
3766            }
3767            IntrospectAction::PluginState => {
3768                let recording_active = self.state.recorder.is_recording();
3769                let recording_events = self.state.recorder.event_count();
3770                let result = serde_json::json!({
3771                    "event_log": {
3772                        "size": self.state.event_log.len(),
3773                        "capacity": self.state.event_log.capacity(),
3774                    },
3775                    "registry": {
3776                        "commands_registered": self.state.registry.list().len(),
3777                    },
3778                    "recording": {
3779                        "active": recording_active,
3780                        "events_captured": recording_events,
3781                    },
3782                    "faults": {
3783                        "active_rules": self.state.fault_registry.list().len(),
3784                    },
3785                    "contracts": {
3786                        "baselines_recorded": self.state.contract_store.all().len(),
3787                    },
3788                    "timings": {
3789                        "commands_profiled": self.state.command_timings.all_stats().len(),
3790                    },
3791                    "event_bus": {
3792                        "captured_events": self.state.event_bus.len(),
3793                    },
3794                    "tasks": {
3795                        "total": self.state.task_tracker.list().len(),
3796                        "active": self.state.task_tracker.active_count(),
3797                    },
3798                    "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
3799                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
3800                    "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
3801                });
3802                json_result(&result)
3803            }
3804            IntrospectAction::Processes => {
3805                let pid = std::process::id();
3806                let uptime = self.state.started_at.elapsed();
3807                let children = crate::introspection::enumerate_child_processes();
3808                let host_memory = crate::memory::current_stats();
3809
3810                let result = serde_json::json!({
3811                    "host": {
3812                        "pid": pid,
3813                        "uptime_secs": uptime.as_secs(),
3814                        "platform": std::env::consts::OS,
3815                        "arch": std::env::consts::ARCH,
3816                        "memory": host_memory,
3817                    },
3818                    "children": children.iter().map(|c| serde_json::json!({
3819                        "pid": c.pid,
3820                        "name": c.name,
3821                        "memory_bytes": c.memory_bytes,
3822                    })).collect::<Vec<_>>(),
3823                    "child_count": children.len(),
3824                    "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
3825                });
3826                json_result(&result)
3827            }
3828            IntrospectAction::PluginTasks => {
3829                let tasks = self.state.task_tracker.list();
3830                let active = self.state.task_tracker.active_count();
3831                let result = serde_json::json!({
3832                    "total": tasks.len(),
3833                    "active": active,
3834                    "finished": tasks.len() - active,
3835                    "tasks": tasks,
3836                });
3837                json_result(&result)
3838            }
3839            IntrospectAction::EventBus => {
3840                // Default cap so the full buffers (up to 1k Tauri + 10k app events, often
3841                // megabytes / tens of thousands of lines) can never overflow the tool result
3842                // cap (VIC-4). Newest events first; `count` is the full total so a truncated
3843                // slice is always diagnosable. Optional `limit` / `since_ms` are read from the
3844                // generic `args` object (a dedicated public field would be a semver-major break).
3845                let opts = params.args.as_ref();
3846                let limit = opts
3847                    .and_then(|a| a.get("limit"))
3848                    .and_then(serde_json::Value::as_u64)
3849                    .and_then(|n| usize::try_from(n).ok())
3850                    .unwrap_or(100);
3851                let since_ms = opts
3852                    .and_then(|a| a.get("since_ms"))
3853                    .and_then(serde_json::Value::as_u64);
3854                let cutoff = since_ms.map(|ms| bounded::ms_ago(chrono::Utc::now(), ms));
3855
3856                let all_tauri = self.state.event_bus.events();
3857                let tauri_total = all_tauri.len();
3858                let tauri_matched: Vec<_> = all_tauri
3859                    .into_iter()
3860                    .filter(|e| match cutoff {
3861                        Some(cut) => chrono::DateTime::parse_from_rfc3339(&e.timestamp)
3862                            .map_or(true, |t| t.with_timezone(&chrono::Utc) >= cut),
3863                        None => true,
3864                    })
3865                    .collect();
3866                let tauri_matched_count = tauri_matched.len();
3867                let tauri_events: Vec<_> = tauri_matched.into_iter().rev().take(limit).collect();
3868
3869                // Exclude Victauri's own infrastructure events (plugin:victauri|* IPC etc.) —
3870                // noise in a diagnostic timeline; the `explain` tools already filter them via
3871                // `is_internal()`.
3872                let all_app: Vec<_> = self
3873                    .state
3874                    .event_log
3875                    .snapshot()
3876                    .into_iter()
3877                    .filter(|e| !e.is_internal())
3878                    .collect();
3879                let app_total = all_app.len();
3880                let app_matched: Vec<_> = match cutoff {
3881                    Some(cut) => all_app
3882                        .into_iter()
3883                        .filter(|e| e.timestamp() >= cut)
3884                        .collect(),
3885                    None => all_app,
3886                };
3887                let app_matched_count = app_matched.len();
3888                let app_events: Vec<_> = app_matched.into_iter().rev().take(limit).collect();
3889
3890                let result = serde_json::json!({
3891                    "limit": limit,
3892                    "since_ms": since_ms,
3893                    "tauri_events": {
3894                        "count": tauri_total,
3895                        "matched": tauri_matched_count,
3896                        "returned": tauri_events.len(),
3897                        "truncated": tauri_matched_count > tauri_events.len(),
3898                        "events": tauri_events,
3899                    },
3900                    "app_events": {
3901                        "count": app_total,
3902                        "matched": app_matched_count,
3903                        "returned": app_events.len(),
3904                        "truncated": app_matched_count > app_events.len(),
3905                        "capacity": self.state.event_log.capacity(),
3906                        "events": app_events,
3907                    },
3908                });
3909                json_result(&result)
3910            }
3911            IntrospectAction::EventBusClear => {
3912                let tauri_cleared = self.state.event_bus.clear();
3913                self.state.event_log.clear();
3914                json_result(&serde_json::json!({
3915                    "tauri_events_cleared": tauri_cleared,
3916                    "app_events_cleared": true,
3917                }))
3918            }
3919        }
3920    }
3921
3922    // ── Fault Injection / Chaos Engineering ──────────────────────────────────
3923
3924    #[tool(
3925        description = "Probe a backend command handler under failure by faulting it for chaos engineering. \
3926            Simulate slow commands, backend errors, dropped responses, and corrupted data. \
3927            SCOPE: faults apply ONLY to commands you run via this server's `invoke_command` tool — \
3928            they do NOT touch the app's real user-driven IPC (window.__TAURI_INTERNALS__.invoke). Use \
3929            this to test a handler's error path when YOU drive it. To make a call the user's click \
3930            triggers fail or slow down, use a `route` rule on its IPC URL instead (fetch transport \
3931            only).\n\n\
3932            Actions:\n\
3933            - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms` \
3934              (max 120000), `error_message`, `max_triggers`. `delay` sleeps then runs the command; \
3935              `error` returns the error without running it; `drop` returns `{}` without running \
3936              it; `corrupt` runs it and replaces the response with a fixed \
3937              `{\"__corrupted\":true,…}` marker. Rules expire after 15 minutes.\n\
3938            - `list`: List all active fault injection rules.\n\
3939            - `clear`: Remove a specific fault rule (requires `command`).\n\
3940            - `clear_all`: Remove all fault rules.",
3941        annotations(
3942            read_only_hint = false,
3943            destructive_hint = true,
3944            idempotent_hint = false,
3945            open_world_hint = false
3946        )
3947    )]
3948    async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
3949        if !self.state.privacy.is_tool_enabled("fault") {
3950            return tool_disabled("fault");
3951        }
3952
3953        match params.action {
3954            FaultAction::Inject => {
3955                let Some(command) = params.command else {
3956                    return missing_param("command", "inject");
3957                };
3958                let Some(fault_kind) = params.fault_type else {
3959                    return missing_param("fault_type", "inject");
3960                };
3961                let fault_type = match fault_kind {
3962                    FaultKind::Delay => {
3963                        let delay_ms = params.delay_ms.unwrap_or(1000);
3964                        // Each delayed call holds a server concurrency slot for its duration;
3965                        // an unbounded delay (u64 ms) would pin slots effectively forever.
3966                        if delay_ms > MAX_FAULT_DELAY_MS {
3967                            return tool_error(format!(
3968                                "delay_ms {delay_ms} exceeds the maximum of {MAX_FAULT_DELAY_MS} ms"
3969                            ));
3970                        }
3971                        crate::introspection::FaultType::Delay { delay_ms }
3972                    }
3973                    FaultKind::Error => {
3974                        let message = params
3975                            .error_message
3976                            .unwrap_or_else(|| "injected fault".to_string());
3977                        crate::introspection::FaultType::Error { message }
3978                    }
3979                    FaultKind::Drop => crate::introspection::FaultType::Drop,
3980                    FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
3981                };
3982                let config = crate::introspection::FaultConfig {
3983                    command: command.clone(),
3984                    fault_type: fault_type.clone(),
3985                    trigger_count: 0,
3986                    max_triggers: params.max_triggers.unwrap_or(0),
3987                    created_at: std::time::Instant::now(),
3988                };
3989                self.state.fault_registry.inject(config);
3990                let result = serde_json::json!({
3991                    "injected": true,
3992                    "command": command,
3993                    "fault_type": fault_type,
3994                    "max_triggers": params.max_triggers.unwrap_or(0),
3995                });
3996                json_result(&result)
3997            }
3998            FaultAction::List => {
3999                let faults = self.state.fault_registry.list();
4000                let result = serde_json::json!({
4001                    "count": faults.len(),
4002                    "faults": faults.iter().map(|f| serde_json::json!({
4003                        "command": f.command,
4004                        "fault_type": f.fault_type,
4005                        "trigger_count": f.trigger_count,
4006                        "max_triggers": f.max_triggers,
4007                    })).collect::<Vec<_>>(),
4008                });
4009                json_result(&result)
4010            }
4011            FaultAction::Clear => {
4012                let Some(command) = params.command else {
4013                    return missing_param("command", "clear");
4014                };
4015                let removed = self.state.fault_registry.clear(&command);
4016                json_result(&serde_json::json!({
4017                    "removed": removed,
4018                    "command": command,
4019                }))
4020            }
4021            FaultAction::ClearAll => {
4022                let removed = self.state.fault_registry.clear_all();
4023                json_result(&serde_json::json!({
4024                    "removed": removed,
4025                }))
4026            }
4027        }
4028    }
4029
4030    // ── Cross-Layer Explanation ────────────────────────────────────────────
4031
4032    #[tool(
4033        description = "Correlate recent activity across all layers into a coherent narrative. \
4034            CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
4035            + window events across the Rust backend and webview simultaneously.\n\n\
4036            Actions:\n\
4037            - `summary`: High-level activity summary for the last N seconds (default 30). \
4038              Counts IPC calls, DOM mutations, console entries, state changes (incl. network \
4039              requests), errors. With no recording active it reads the window's live event \
4040              stream (`webview_label`, default main).\n\
4041            - `last_action`: Correlate the most recent burst of events into a causal timeline \
4042              (e.g. 'IPC call → DOM update → console.log').\n\
4043            - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
4044        annotations(
4045            read_only_hint = true,
4046            destructive_hint = false,
4047            idempotent_hint = true,
4048            open_world_hint = false
4049        )
4050    )]
4051    async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
4052        if !self.state.privacy.is_tool_enabled("explain") {
4053            return tool_disabled("explain");
4054        }
4055
4056        match params.action {
4057            ExplainAction::Summary => {
4058                let secs = params.seconds.unwrap_or(30);
4059                let since = bounded::secs_ago(chrono::Utc::now(), secs);
4060                let events = self
4061                    .explain_events(since, params.webview_label.as_deref())
4062                    .await;
4063
4064                let mut ipc_count = 0u64;
4065                let mut dom_mutations = 0u64;
4066                let mut state_changes = 0u64;
4067                let mut console_count = 0u64;
4068                let mut window_events = 0u64;
4069                let mut interactions = 0u64;
4070                let mut top_commands: HashMap<String, u64> = HashMap::new();
4071                let mut errors: Vec<String> = Vec::new();
4072
4073                for event in &events {
4074                    match event {
4075                        victauri_core::AppEvent::Ipc(call) => {
4076                            ipc_count += 1;
4077                            *top_commands.entry(call.command.clone()).or_insert(0) += 1;
4078                            if let victauri_core::IpcResult::Err(e) = &call.result {
4079                                errors.push(format!("IPC {}: {e}", call.command));
4080                            }
4081                        }
4082                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
4083                            dom_mutations += u64::from(*mutation_count)
4084                        }
4085                        victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
4086                        victauri_core::AppEvent::Console { level, message, .. } => {
4087                            console_count += 1;
4088                            if level == "error" {
4089                                errors.push(format!("console.error: {message}"));
4090                            }
4091                        }
4092                        victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
4093                        victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
4094                        _ => {}
4095                    }
4096                }
4097
4098                let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
4099                sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
4100                let top: Vec<_> = sorted_cmds.iter().take(5).collect();
4101
4102                let narrative = format!(
4103                    "{ipc_count} IPC call{} in the last {secs}s{}. \
4104                     {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
4105                     {console_count} console message{}, {window_events} window event{}. {}.",
4106                    if ipc_count == 1 { "" } else { "s" },
4107                    if top.is_empty() {
4108                        String::new()
4109                    } else {
4110                        format!(
4111                            ", dominated by {}",
4112                            top.iter()
4113                                .map(|(cmd, n)| format!("{cmd} ({n}x)"))
4114                                .collect::<Vec<_>>()
4115                                .join(", ")
4116                        )
4117                    },
4118                    if dom_mutations == 1 { "" } else { "s" },
4119                    if interactions == 1 { "" } else { "s" },
4120                    if console_count == 1 { "" } else { "s" },
4121                    if window_events == 1 { "" } else { "s" },
4122                    if errors.is_empty() {
4123                        "No errors".to_string()
4124                    } else {
4125                        format!(
4126                            "{} error{}",
4127                            errors.len(),
4128                            if errors.len() == 1 { "" } else { "s" }
4129                        )
4130                    },
4131                );
4132
4133                let result = serde_json::json!({
4134                    "time_window_secs": secs,
4135                    "total_events": events.len(),
4136                    "ipc_calls": ipc_count,
4137                    "dom_mutations": dom_mutations,
4138                    "state_changes": state_changes,
4139                    "console_messages": console_count,
4140                    "window_events": window_events,
4141                    "interactions": interactions,
4142                    "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
4143                        serde_json::json!({"command": cmd, "count": n})
4144                    }).collect::<Vec<_>>(),
4145                    "errors": errors,
4146                    "narrative": narrative,
4147                });
4148                json_result(&result)
4149            }
4150            ExplainAction::LastAction => {
4151                let secs = params.seconds.unwrap_or(5);
4152                let since = bounded::secs_ago(chrono::Utc::now(), secs);
4153                let events = self
4154                    .explain_events(since, params.webview_label.as_deref())
4155                    .await;
4156
4157                let timeline: Vec<serde_json::Value> = events
4158                    .iter()
4159                    .filter(|e| !e.is_internal())
4160                    .map(|event| match event {
4161                        victauri_core::AppEvent::Ipc(call) => serde_json::json!({
4162                            "time": call.timestamp.to_rfc3339_opts(
4163                                chrono::SecondsFormat::Millis, true
4164                            ),
4165                            "type": "ipc",
4166                            "detail": format!(
4167                                "{} {} ({}ms)",
4168                                call.command,
4169                                call.result,
4170                                call.duration_ms.unwrap_or(0)
4171                            ),
4172                        }),
4173                        victauri_core::AppEvent::DomMutation {
4174                            timestamp,
4175                            mutation_count,
4176                            webview_label,
4177                            ..
4178                        } => serde_json::json!({
4179                            "time": timestamp.to_rfc3339_opts(
4180                                chrono::SecondsFormat::Millis, true
4181                            ),
4182                            "type": "dom_mutation",
4183                            "detail": format!(
4184                                "{mutation_count} element{} updated in {webview_label}",
4185                                if *mutation_count == 1 { "" } else { "s" }
4186                            ),
4187                        }),
4188                        victauri_core::AppEvent::DomInteraction {
4189                            timestamp,
4190                            action,
4191                            selector,
4192                            ..
4193                        } => serde_json::json!({
4194                            "time": timestamp.to_rfc3339_opts(
4195                                chrono::SecondsFormat::Millis, true
4196                            ),
4197                            "type": "interaction",
4198                            "detail": format!("{action} on {selector}"),
4199                        }),
4200                        victauri_core::AppEvent::StateChange {
4201                            timestamp,
4202                            key,
4203                            caused_by,
4204                            ..
4205                        } => serde_json::json!({
4206                            "time": timestamp.to_rfc3339_opts(
4207                                chrono::SecondsFormat::Millis, true
4208                            ),
4209                            "type": "state_change",
4210                            "detail": format!(
4211                                "{key} changed{}",
4212                                caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
4213                            ),
4214                        }),
4215                        victauri_core::AppEvent::Console {
4216                            timestamp,
4217                            level,
4218                            message,
4219                            ..
4220                        } => serde_json::json!({
4221                            "time": timestamp.to_rfc3339_opts(
4222                                chrono::SecondsFormat::Millis, true
4223                            ),
4224                            "type": "console",
4225                            "detail": format!("console.{level}: {message}"),
4226                        }),
4227                        victauri_core::AppEvent::WindowEvent {
4228                            timestamp,
4229                            label,
4230                            event,
4231                            ..
4232                        } => serde_json::json!({
4233                            "time": timestamp.to_rfc3339_opts(
4234                                chrono::SecondsFormat::Millis, true
4235                            ),
4236                            "type": "window_event",
4237                            "detail": format!("{event} on window '{label}'"),
4238                        }),
4239                        _ => serde_json::json!({
4240                            "time": event.timestamp().to_rfc3339_opts(
4241                                chrono::SecondsFormat::Millis, true
4242                            ),
4243                            "type": "other",
4244                            "detail": "unknown event type",
4245                        }),
4246                    })
4247                    .collect();
4248
4249                let narrative = if timeline.is_empty() {
4250                    format!("No activity in the last {secs}s.")
4251                } else {
4252                    let parts: Vec<String> = timeline
4253                        .iter()
4254                        .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
4255                        .map(String::from)
4256                        .collect();
4257                    parts.join(" → ")
4258                };
4259
4260                let result = serde_json::json!({
4261                    "time_window_secs": secs,
4262                    "event_count": timeline.len(),
4263                    "timeline": timeline,
4264                    "narrative": narrative,
4265                });
4266                json_result(&result)
4267            }
4268            ExplainAction::Diff => {
4269                let secs = params.seconds.unwrap_or(10);
4270                let since = bounded::secs_ago(chrono::Utc::now(), secs);
4271                let events = self
4272                    .explain_events(since, params.webview_label.as_deref())
4273                    .await;
4274
4275                let mut ipc_commands: Vec<String> = Vec::new();
4276                let mut dom_changes = 0u64;
4277                let mut error_count = 0u64;
4278                let mut interaction_count = 0u64;
4279                let mut console_messages = 0u64;
4280
4281                for event in &events {
4282                    if event.is_internal() {
4283                        continue;
4284                    }
4285                    match event {
4286                        victauri_core::AppEvent::Ipc(call) => {
4287                            ipc_commands.push(call.command.clone());
4288                            if matches!(call.result, victauri_core::IpcResult::Err(_)) {
4289                                error_count += 1;
4290                            }
4291                        }
4292                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
4293                            dom_changes += u64::from(*mutation_count)
4294                        }
4295                        victauri_core::AppEvent::DomInteraction { .. } => {
4296                            interaction_count += 1;
4297                        }
4298                        victauri_core::AppEvent::Console { level, .. } => {
4299                            console_messages += 1;
4300                            if level == "error" {
4301                                error_count += 1;
4302                            }
4303                        }
4304                        _ => {}
4305                    }
4306                }
4307
4308                // Every call counts; each command is listed once, in first-call order (a
4309                // consecutive-only `dedup` used to undercount the calls and still list a
4310                // command once per non-adjacent run).
4311                let ipc_calls_made = ipc_commands.len();
4312                let mut seen = HashSet::new();
4313                ipc_commands.retain(|c| seen.insert(c.clone()));
4314
4315                let result = serde_json::json!({
4316                    "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
4317                    "time_window_secs": secs,
4318                    "total_events": events.len(),
4319                    "ipc_calls_made": ipc_calls_made,
4320                    "unique_commands": ipc_commands,
4321                    "dom_elements_changed": dom_changes,
4322                    "interactions": interaction_count,
4323                    "console_messages": console_messages,
4324                    "errors": error_count,
4325                });
4326                json_result(&result)
4327            }
4328        }
4329    }
4330}
4331
4332impl VictauriMcpHandler {
4333    /// Create a new handler backed by the given state and webview bridge.
4334    pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
4335        Self {
4336            state,
4337            bridge,
4338            subscriptions: Arc::new(Mutex::new(HashSet::new())),
4339            bridge_checked: Arc::new(AtomicBool::new(false)),
4340            timed_out_labels: Arc::new(Mutex::new(HashSet::new())),
4341            probe_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_PROBES)),
4342            file_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_FILE_READS)),
4343            probe_timeout: PROBE_TIMEOUT,
4344            file_read_timeout: READ_APP_FILE_TIMEOUT,
4345        }
4346    }
4347
4348    pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
4349        self.state.privacy.is_tool_enabled(name)
4350    }
4351
4352    pub(crate) async fn execute_tool(
4353        &self,
4354        name: &str,
4355        args: serde_json::Value,
4356    ) -> Result<CallToolResult, rest::ToolCallError> {
4357        // Centralized authorization: resolve the canonical `tool.action` capability
4358        // and gate on it BEFORE dispatch, so every compound action is checked
4359        // uniformly (not just the ones whose handler remembers to). See `authz`.
4360        // A disabled tool reports "disabled" whatever its arguments look like.
4361        if self.state.privacy.disabled_tools.contains(name) {
4362            return Ok(tool_disabled(name));
4363        }
4364        let capability =
4365            authz::resolve_capability(name, &args).map_err(rest::ToolCallError::InvalidParams)?;
4366        if !self.state.privacy.is_call_allowed(name, &capability) {
4367            return Ok(tool_disabled(&capability));
4368        }
4369        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
4370        let start = std::time::Instant::now();
4371        tracing::debug!(tool = %name, "REST tool invocation started");
4372
4373        // A panicking handler becomes an error RESULT; without this boundary it unwound into
4374        // hyper's connection task and the client saw a reset connection.
4375        let result = match bounded::CatchUnwind::new(self.dispatch_tool(name, args)).await {
4376            Ok(dispatched) => dispatched?,
4377            Err(panic) => {
4378                tracing::error!(tool = %name, "tool handler panicked: {panic}");
4379                tool_panicked(name, &panic)
4380            }
4381        };
4382
4383        let elapsed = start.elapsed();
4384        tracing::debug!(
4385            tool = %name,
4386            elapsed_ms = elapsed.as_millis() as u64,
4387            "REST tool invocation completed"
4388        );
4389
4390        if self.state.privacy.redaction_enabled {
4391            Ok(Self::redact_result(result, &self.state.privacy))
4392        } else {
4393            Ok(result)
4394        }
4395    }
4396
4397    /// Route one already-authorized REST call to its tool handler.
4398    async fn dispatch_tool(
4399        &self,
4400        name: &str,
4401        args: serde_json::Value,
4402    ) -> Result<CallToolResult, rest::ToolCallError> {
4403        let result = match name {
4404            "eval_js" => {
4405                let p: EvalJsParams = Self::parse_args(args)?;
4406                self.eval_js(Parameters(p)).await
4407            }
4408            "dom_snapshot" => {
4409                let p: SnapshotParams = Self::parse_args(args)?;
4410                self.dom_snapshot(Parameters(p)).await
4411            }
4412            "find_elements" => {
4413                let p: FindElementsParams = Self::parse_args(args)?;
4414                self.find_elements(Parameters(p)).await
4415            }
4416            "invoke_command" => {
4417                let p: InvokeCommandParams = Self::parse_args(args)?;
4418                self.invoke_command(Parameters(p)).await
4419            }
4420            "screenshot" => {
4421                let p: ScreenshotParams = Self::parse_args(args)?;
4422                self.screenshot(Parameters(p)).await
4423            }
4424            "verify_state" => {
4425                let p: VerifyStateParams = Self::parse_args(args)?;
4426                self.verify_state(Parameters(p)).await
4427            }
4428            "detect_ghost_commands" => {
4429                let p: GhostCommandParams = Self::parse_args(args)?;
4430                self.detect_ghost_commands(Parameters(p)).await
4431            }
4432            "check_ipc_integrity" => {
4433                let p: IpcIntegrityParams = Self::parse_args(args)?;
4434                self.check_ipc_integrity(Parameters(p)).await
4435            }
4436            "wait_for" => {
4437                let p: WaitForParams = Self::parse_args(args)?;
4438                self.wait_for(Parameters(p)).await
4439            }
4440            "assert_semantic" => {
4441                let p: SemanticAssertParams = Self::parse_args(args)?;
4442                self.assert_semantic(Parameters(p)).await
4443            }
4444            "resolve_command" => {
4445                let p: ResolveCommandParams = Self::parse_args(args)?;
4446                self.resolve_command(Parameters(p)).await
4447            }
4448            "get_registry" => {
4449                let p: RegistryParams = Self::parse_args(args)?;
4450                self.get_registry(Parameters(p)).await
4451            }
4452            "app_state" => {
4453                let p: AppStateParams = Self::parse_args(args)?;
4454                self.app_state(Parameters(p)).await
4455            }
4456            "get_memory_stats" => self.get_memory_stats().await,
4457            "get_plugin_info" => self.get_plugin_info().await,
4458            "get_diagnostics" => {
4459                let p: DiagnosticsParams = Self::parse_args(args)?;
4460                self.get_diagnostics(Parameters(p)).await
4461            }
4462            "app_info" => self.app_info().await,
4463            "list_app_dir" => {
4464                let p: ListAppDirParams = Self::parse_args(args)?;
4465                self.list_app_dir(Parameters(p)).await
4466            }
4467            "read_app_file" => {
4468                let p: ReadAppFileParams = Self::parse_args(args)?;
4469                self.read_app_file(Parameters(p)).await
4470            }
4471            "query_db" => {
4472                let p: QueryDbParams = Self::parse_args(args)?;
4473                self.query_db(Parameters(p)).await
4474            }
4475            "interact" => {
4476                let p: InteractParams = Self::parse_args(args)?;
4477                self.interact(Parameters(p)).await
4478            }
4479            "input" => {
4480                let p: InputParams = Self::parse_args(args)?;
4481                self.input(Parameters(p)).await
4482            }
4483            "window" => {
4484                let p: WindowParams = Self::parse_args(args)?;
4485                self.window(Parameters(p)).await
4486            }
4487            "storage" => {
4488                let p: StorageParams = Self::parse_args(args)?;
4489                self.storage(Parameters(p)).await
4490            }
4491            "navigate" => {
4492                let p: NavigateParams = Self::parse_args(args)?;
4493                self.navigate(Parameters(p)).await
4494            }
4495            "recording" => {
4496                let p: RecordingParams = Self::parse_args(args)?;
4497                self.recording(Parameters(p)).await
4498            }
4499            "inspect" => {
4500                let p: InspectParams = Self::parse_args(args)?;
4501                self.inspect(Parameters(p)).await
4502            }
4503            "css" => {
4504                let p: CssParams = Self::parse_args(args)?;
4505                self.css(Parameters(p)).await
4506            }
4507            "route" => {
4508                let p: RouteParams = Self::parse_args(args)?;
4509                self.route(Parameters(p)).await
4510            }
4511            "trace" => {
4512                let p: TraceParams = Self::parse_args(args)?;
4513                self.trace(Parameters(p)).await
4514            }
4515            "animation" => {
4516                let p: AnimationParams = Self::parse_args(args)?;
4517                self.animation(Parameters(p)).await
4518            }
4519            "logs" => {
4520                let p: LogsParams = Self::parse_args(args)?;
4521                self.logs(Parameters(p)).await
4522            }
4523            "introspect" => {
4524                let p: IntrospectParams = Self::parse_args(args)?;
4525                self.introspect(Parameters(p)).await
4526            }
4527            "fault" => {
4528                let p: FaultParams = Self::parse_args(args)?;
4529                self.fault(Parameters(p)).await
4530            }
4531            "explain" => {
4532                let p: ExplainParams = Self::parse_args(args)?;
4533                self.explain(Parameters(p)).await
4534            }
4535            _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
4536        };
4537        Ok(result)
4538    }
4539
4540    fn parse_args<T: serde::de::DeserializeOwned>(
4541        args: serde_json::Value,
4542    ) -> Result<T, rest::ToolCallError> {
4543        serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
4544    }
4545
4546    fn redact_result(
4547        mut result: CallToolResult,
4548        privacy: &crate::privacy::PrivacyConfig,
4549    ) -> CallToolResult {
4550        for item in &mut result.content {
4551            if let ContentBlock::Text(tc) = item {
4552                tc.text = privacy.redact_output(&tc.text);
4553            }
4554        }
4555        result
4556    }
4557
4558    /// Read at most `max_bytes` (+1, to detect truncation) of the regular file at `path` on
4559    /// the blocking pool, within [`READ_APP_FILE_TIMEOUT`] and one of the
4560    /// [`MAX_CONCURRENT_FILE_READS`] slots. A read that blocks (a FIFO or device swapped in
4561    /// after the handler's checks) returns at the deadline; its thread keeps the slot until
4562    /// it finishes, so such reads cannot pile up.
4563    async fn read_regular_file_bounded(
4564        &self,
4565        path: std::path::PathBuf,
4566        max_bytes: usize,
4567    ) -> Result<(Vec<u8>, usize, Option<u64>), String> {
4568        let Ok(slot) = Arc::clone(&self.file_slots).try_acquire_owned() else {
4569            return Err(format!(
4570                "file reads are busy ({MAX_CONCURRENT_FILE_READS} still running — a read \
4571                 blocked on a pipe or device keeps running past its timeout). Retry shortly."
4572            ));
4573        };
4574        bounded::run_blocking_bounded(None, "file read", self.file_read_timeout, move || {
4575            let _slot = slot;
4576            read_regular_file(&path, max_bytes)
4577        })
4578        .await
4579    }
4580
4581    fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
4582        match dir.unwrap_or(AppDir::Data) {
4583            AppDir::Data => self.bridge.app_data_dir(),
4584            AppDir::Config => self.bridge.app_config_dir(),
4585            AppDir::Log => self.bridge.app_log_dir(),
4586            AppDir::LocalData => self.bridge.app_local_data_dir(),
4587        }
4588    }
4589
4590    /// Lexical (pre-existence) traversal guard for a user-supplied sub-path.
4591    ///
4592    /// Rejects absolute paths and any component that is `..` BEFORE the path is
4593    /// canonicalized. This is necessary because [`Self::safe_within`] relies on
4594    /// `canonicalize`, which errors on non-existent paths — so a traversal
4595    /// attempt against a missing target would otherwise be reported as
4596    /// "not found" (an info-leak oracle) rather than as traversal.
4597    fn lexical_safe(sub: &std::path::Path) -> Result<(), String> {
4598        use std::path::Component;
4599        if sub.is_absolute() {
4600            return Err("path traversal not allowed: absolute paths are rejected".to_string());
4601        }
4602        for component in sub.components() {
4603            match component {
4604                Component::ParentDir => {
4605                    return Err("path traversal not allowed: '..' is rejected".to_string());
4606                }
4607                Component::Prefix(_) | Component::RootDir => {
4608                    return Err(
4609                        "path traversal not allowed: absolute paths are rejected".to_string()
4610                    );
4611                }
4612                Component::CurDir | Component::Normal(_) => {}
4613            }
4614        }
4615        Ok(())
4616    }
4617
4618    fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
4619        let canon_base = std::fs::canonicalize(base)
4620            .map_err(|e| format!("cannot resolve base directory: {e}"))?;
4621        let canon_target = std::fs::canonicalize(target)
4622            .map_err(|e| format!("cannot resolve target path: {e}"))?;
4623        if !canon_target.starts_with(&canon_base) {
4624            return Err("path traversal not allowed".to_string());
4625        }
4626        Ok(())
4627    }
4628
4629    #[cfg(feature = "sqlite")]
4630    fn resolve_existing_db_path(
4631        roots: &[std::path::PathBuf],
4632        requested: &str,
4633    ) -> Result<std::path::PathBuf, String> {
4634        let candidate = std::path::Path::new(requested);
4635        // One answer for "missing" and "resolves outside every root" (a symlink inside a root
4636        // pointing elsewhere): distinct answers told the caller whether an arbitrary path
4637        // exists on disk.
4638        let not_found = || {
4639            format!(
4640                "database not found (or it resolves outside the allowed directories): {requested}"
4641            )
4642        };
4643        if candidate.is_absolute() {
4644            // `root/link/../y` normalizes lexically to `root/y`, but the OS resolves `..` AFTER
4645            // following `link` — so the existence check probed a path outside every root.
4646            if candidate
4647                .components()
4648                .any(|c| matches!(c, std::path::Component::ParentDir))
4649            {
4650                return Err("path traversal not allowed: '..' is rejected".to_string());
4651            }
4652            // Decide containment LEXICALLY first, before touching the requested path: checking
4653            // existence first answered "does X exist?" for any path on disk ("not found" vs
4654            // "not within an allowed directory"). The canonical check below still catches
4655            // symlink escapes for paths that are lexically inside a root.
4656            let normalized = lexically_normalize(candidate);
4657            let lexically_inside = roots.iter().any(|root| {
4658                normalized.starts_with(lexically_normalize(root))
4659                    || std::fs::canonicalize(root)
4660                        .is_ok_and(|canon_root| normalized.starts_with(canon_root))
4661            });
4662            if !lexically_inside {
4663                return Err(format!(
4664                    "absolute path '{requested}' is not within an allowed directory; \
4665                     register its parent via VictauriBuilder::db_search_paths"
4666                ));
4667            }
4668            if candidate.exists()
4669                && roots
4670                    .iter()
4671                    .any(|root| Self::safe_within(root, candidate).is_ok())
4672            {
4673                // Open the CANONICAL validated path, not the caller's literal absolute path,
4674                // so the DB is opened at exactly the containment-approved location — symmetric
4675                // with the relative branch below and closing the validate-canonical/open-lexical
4676                // TOCTOU on this branch (a same-privilege symlink swap between canonicalize and
4677                // open is the unavoidable residual, documented in security.md).
4678                let canonical = std::fs::canonicalize(candidate)
4679                    .map_err(|e| format!("cannot resolve database path: {e}"))?;
4680                return Ok(canonical);
4681            }
4682            return Err(not_found());
4683        }
4684
4685        Self::lexical_safe(candidate)?;
4686        for root in roots {
4687            let resolved = root.join(candidate);
4688            // A match that escapes its root is treated exactly like a miss (see `not_found`).
4689            if resolved.exists() && Self::safe_within(root, &resolved).is_ok() {
4690                // Open the CANONICAL validated path, not the lexical join, so the DB is opened
4691                // at exactly the path containment approved (closes the trivial validate-lexical
4692                // vs open-lexical TOCTOU; a same-privilege local symlink swap between
4693                // canonicalize and open is the unavoidable residual, documented in security.md).
4694                let canonical = std::fs::canonicalize(&resolved)
4695                    .map_err(|e| format!("cannot resolve database path: {e}"))?;
4696                return Ok(canonical);
4697            }
4698        }
4699
4700        let roots = roots
4701            .iter()
4702            .map(|root| root.display().to_string())
4703            .collect::<Vec<_>>()
4704            .join(", ");
4705        Err(format!("{} (searched: {roots})", not_found()))
4706    }
4707
4708    /// Events for `explain` since `since`. While a recording is active the background drain
4709    /// keeps `event_log` current, so read it. Otherwise nothing drains (idle draining was the
4710    /// 0.8.x host-crash amplifier), so read the bridge's own event stream ON DEMAND — without
4711    /// writing it into `event_log`, so a later recording never double-counts it. Before this,
4712    /// `explain` silently reported "0 IPC calls, 0 DOM mutations…" for any app not recording.
4713    async fn explain_events(
4714        &self,
4715        since: chrono::DateTime<chrono::Utc>,
4716        webview_label: Option<&str>,
4717    ) -> Vec<victauri_core::AppEvent> {
4718        if self.state.recorder.is_recording() {
4719            return self.state.event_log.since(since);
4720        }
4721        let code = format!(
4722            "return window.__VICTAURI__?.getEventStream({})",
4723            since.timestamp_millis()
4724        );
4725        let Ok(raw) = self.eval_with_return(&code, webview_label).await else {
4726            return self.state.event_log.since(since);
4727        };
4728        let label = webview_label.unwrap_or("main");
4729        let mut events: Vec<victauri_core::AppEvent> =
4730            page_json::parse_page_json::<Vec<serde_json::Value>>(&raw)
4731                .unwrap_or_default()
4732                .iter()
4733                .filter_map(|ev| crate::mcp::server::parse_bridge_event_from(ev, label))
4734                .filter(|ev| !ev.is_internal() && ev.timestamp() >= since)
4735                .collect();
4736        events.sort_by_key(victauri_core::AppEvent::timestamp);
4737        events
4738    }
4739
4740    /// Resolve the EXACT window a native capture (`screenshot`, `trace`) should target, and
4741    /// require it to be visible. A native capture reads the on-screen surface; a hidden window
4742    /// has none, so the OS path (`PrintWindow` / `CGWindowListCreateImage`) silently returns
4743    /// stale, empty, or ANOTHER window's pixels with no error. Two failure modes are closed:
4744    ///   1. (live-4DA, 2026-06-16) an explicitly-requested hidden window (label:"briefing")
4745    ///      returned the MAIN window's pixels.
4746    ///   2. (GPT audit, P2) with no label, `find_window(None)` prefers "main" UNCONDITIONALLY,
4747    ///      so an app that hides main but leaves a secondary window visible captured hidden
4748    ///      main. `find_window` is left alone — other callers (e.g. eval) may legitimately
4749    ///      target a hidden window — so capture tools resolve their own VISIBLE target.
4750    ///
4751    /// Acknowledged residual: resolve -> handle -> capture are separate calls, so a window
4752    /// hidden in the gap is still TOCTOU; the worst case is a wrong image, not a security
4753    /// boundary.
4754    fn resolve_visible_capture_target(&self, label: Option<&str>) -> Result<String, String> {
4755        let states = self
4756            .bridge
4757            .try_get_window_states(None)
4758            .map_err(|e| ui_busy(&e))?;
4759        if let Some(label) = label {
4760            // An explicit label that the enumerator reports hidden is rejected; visible or
4761            // unknown labels fall through (an unknown one lets get_native_handle produce the
4762            // canonical "window not found" rather than inventing an error here).
4763            if states.iter().any(|s| s.label == label && !s.visible) {
4764                return Err(format!(
4765                    "window '{label}' is not visible — a native screenshot captures the \
4766                     on-screen surface, and a hidden window has none (the OS capture would \
4767                     return stale or another window's pixels). Show it first \
4768                     (window action=manage manage_action=show label={label}), then capture."
4769                ));
4770            }
4771            return Ok(label.to_string());
4772        }
4773        // No label: prefer a visible "main", else the first visible window. NEVER silently
4774        // fall back to a hidden window (the P2 bug) — error instead.
4775        states
4776            .iter()
4777            .find(|s| s.label == "main" && s.visible)
4778            .or_else(|| states.iter().find(|s| s.visible))
4779            .map(|st| st.label.clone())
4780            .ok_or_else(|| {
4781                "no visible window to capture — every window is hidden (or the UI is \
4782                 not responding). Show one first (window action=manage \
4783                 manage_action=show label=<label>), then capture."
4784                    .to_string()
4785            })
4786    }
4787
4788    /// Whether `target` exists, after refusing it if it resolves outside `base`.
4789    ///
4790    /// For a missing target, containment is decided on its deepest EXISTING ancestor, so a
4791    /// path routed through a symlink out of `base` is refused whether or not its final
4792    /// component exists ("missing" vs "outside" would otherwise reveal whether an arbitrary
4793    /// outside path exists).
4794    fn contained_or_missing(
4795        base: &std::path::Path,
4796        target: &std::path::Path,
4797    ) -> Result<bool, String> {
4798        // A missing base holds nothing (and no symlink that could lead out of it).
4799        let Ok(canon_base) = std::fs::canonicalize(base) else {
4800            return Ok(false);
4801        };
4802        let exists = target.exists();
4803        let probe = if exists {
4804            Some(target)
4805        } else {
4806            target.ancestors().skip(1).find(|a| a.exists())
4807        };
4808        if let Some(probe) = probe {
4809            let canonical = std::fs::canonicalize(probe)
4810                .map_err(|e| format!("cannot resolve target path: {e}"))?;
4811            if !canonical.starts_with(&canon_base) {
4812                return Err("path traversal not allowed".to_string());
4813            }
4814        }
4815        Ok(exists)
4816    }
4817
4818    fn matches_glob(name: &str, pattern: &str) -> bool {
4819        if pattern == "*" {
4820            return true;
4821        }
4822        if let Some(suffix) = pattern.strip_prefix("*.") {
4823            return name.ends_with(&format!(".{suffix}"));
4824        }
4825        if let Some(prefix) = pattern.strip_suffix("*") {
4826            return name.starts_with(prefix);
4827        }
4828        name == pattern
4829    }
4830
4831    /// Probe every window's JS bridge and report which are introspectable. A
4832    /// visible window that fails to respond almost always lacks the
4833    /// `victauri:default` capability — Tauri's permission ACL silently blocks
4834    /// the bridge's callback IPC, so eval/dom/animation tools see nothing. This
4835    /// turns that silent dead-end into an actionable, up-front diagnosis.
4836    async fn window_introspectability(&self) -> CallToolResult {
4837        let labels = match self.bridge.try_list_window_labels() {
4838            Ok(labels) => labels,
4839            Err(e) => return tool_error(ui_busy(&e)),
4840        };
4841        let states = match self.bridge.try_get_window_states(None) {
4842            Ok(states) => states,
4843            Err(e) => return tool_error(ui_busy(&e)),
4844        };
4845        let mut report = Vec::with_capacity(labels.len());
4846        let mut blind = 0usize;
4847        for label in &labels {
4848            let visible = states.iter().find(|s| &s.label == label).map(|s| s.visible);
4849            let introspectable = self.probe_bridge(Some(label)).await.is_ok();
4850            if !introspectable {
4851                blind += 1;
4852            }
4853            let note = if introspectable {
4854                "ok — Victauri JS bridge is responding".to_string()
4855            } else if visible == Some(true) {
4856                format!(
4857                    "NOT introspectable although the window is visible — almost certainly missing \
4858                     the Victauri capability. Add \"victauri:default\" to the capability file \
4859                     (src-tauri/capabilities/*.json) whose \"windows\" list includes \"{label}\", \
4860                     then rebuild. Capabilities are baked at compile time, so a rebuild is required."
4861                )
4862            } else {
4863                "NOT introspectable (window is hidden and/or has no bridge) — show the window to \
4864                 confirm, and ensure its capability includes \"victauri:default\", then rebuild."
4865                    .to_string()
4866            };
4867            report.push(serde_json::json!({
4868                "label": label,
4869                "visible": visible,
4870                "introspectable": introspectable,
4871                "note": note,
4872            }));
4873        }
4874        let hint = if blind > 0 {
4875            "Windows with introspectable:false have no working Victauri JS bridge — eval_js, \
4876             dom_snapshot, animation, find_elements, etc. cannot see them. The usual cause is a \
4877             missing \"victauri:default\" capability for that window: Tauri's per-window permission \
4878             ACL silently blocks the bridge's callback IPC. This capability is required per window, \
4879             not just for the main window. (Note: probing a blind window takes ~2s each.)"
4880        } else {
4881            "All windows are introspectable."
4882        };
4883        json_result(&serde_json::json!({
4884            "windows": report,
4885            "introspectable_count": labels.len().saturating_sub(blind),
4886            "blind_count": blind,
4887            "hint": hint,
4888        }))
4889    }
4890
4891    /// Focus `ref_id` before trusted (OS-level) keystrokes and confirm focus LANDED on it —
4892    /// the keys go to whatever holds focus, so an element that exists but did not take focus
4893    /// (not focusable, inert, or a focus handler moved focus on) must stop the input.
4894    async fn focus_for_trusted_input(
4895        &self,
4896        ref_id: &str,
4897        webview_label: Option<&str>,
4898    ) -> Result<(), CallToolResult> {
4899        let raw = self
4900            .eval_with_return(&trusted_focus_probe_js(ref_id), webview_label)
4901            .await
4902            .map_err(|e| {
4903                tool_error(format!(
4904                    "could not focus {ref_id} before sending OS input: {e} — no keys were sent"
4905                ))
4906            })?;
4907        let answer = serde_json::from_str::<serde_json::Value>(&raw).unwrap_or_default();
4908        if answer.get("focused") == Some(&serde_json::Value::Bool(true)) {
4909            return Ok(());
4910        }
4911        let why = if answer.get("found") == Some(&serde_json::Value::Bool(true)) {
4912            "focus did not land on it (not focusable, inert, or a focus handler moved focus \
4913             elsewhere)"
4914        } else {
4915            "ref not found"
4916        };
4917        Err(tool_error_with_hint(
4918            format!(
4919                "ref not found or not focusable: {ref_id}: {why} — no keys were sent (they \
4920                 would have gone to whatever holds focus)"
4921            ),
4922            RecoveryHint::CheckInput,
4923        ))
4924    }
4925
4926    /// The window's IPC capture status (`getIpcCaptureStatus`): which Tauri IPC transport the
4927    /// page uses and how completely the bridge captures it. Best effort — `null` when it cannot
4928    /// be read (an older bridge, an unreachable window) — so it never fails the tool it
4929    /// annotates. Every IPC-log-derived report carries it, so a report built from a partial
4930    /// IPC picture says so instead of looking complete.
4931    async fn ipc_capture_status(&self, webview_label: Option<&str>) -> serde_json::Value {
4932        match self
4933            .eval_with_return_timeout(
4934                IPC_CAPTURE_STATUS_JS,
4935                webview_label,
4936                std::time::Duration::from_secs(5),
4937            )
4938            .await
4939        {
4940            Ok(raw) => page_json::parse_page_json::<serde_json::Value>(&raw)
4941                .ok()
4942                .filter(serde_json::Value::is_object)
4943                .unwrap_or(serde_json::Value::Null),
4944            Err(_) => serde_json::Value::Null,
4945        }
4946    }
4947
4948    async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
4949        match self.eval_with_return(code, webview_label).await {
4950            Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
4951            Err(e) => tool_error(e),
4952        }
4953    }
4954
4955    /// Run a page ACTION (`interact`, `input`, `inspect`, `route add`) and report a failure the
4956    /// page returns — `{ok: false, error, hint}` or `{error}` — as a tool error carrying the
4957    /// page's message and recovery hint. These used to come back as a SUCCESS result, so a
4958    /// refused click (covered, disabled, ref not found) read as done unless the caller parsed
4959    /// the body (R5B-ISERR1).
4960    async fn eval_page_action(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
4961        match self.eval_with_return(code, webview_label).await {
4962            Ok(result) => match page_action_error(&result) {
4963                Some(message) => tool_error(message),
4964                None => CallToolResult::success(vec![ContentBlock::text(result)]),
4965            },
4966            Err(e) => tool_error(e),
4967        }
4968    }
4969
4970    async fn eval_with_return(
4971        &self,
4972        code: &str,
4973        webview_label: Option<&str>,
4974    ) -> Result<String, String> {
4975        self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
4976            .await
4977    }
4978
4979    /// Atomically reserve a pending-eval slot under a SINGLE lock: reject if the map is
4980    /// already at the concurrency ceiling, otherwise insert. This makes `MAX_PENDING_EVALS`
4981    /// a TRUE hard ceiling — a separate check-then-insert races (concurrent callers all pass
4982    /// a stale check, then each inserts, blowing past the cap). On a saturated map it also
4983    /// fails fast (before any eval is injected) with the real "too many concurrent" cause
4984    /// rather than letting a probe burn its full timeout. The slot is released when the
4985    /// returned guard drops — on every exit, including the caller's future being dropped.
4986    async fn reserve_pending(
4987        &self,
4988        id: &str,
4989        tx: tokio::sync::oneshot::Sender<String>,
4990    ) -> Result<crate::PendingSlot, String> {
4991        let mut pending = self.state.pending_evals.lock().await;
4992        if pending.len() >= MAX_PENDING_EVALS {
4993            return Err(format!(
4994                "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
4995            ));
4996        }
4997        Ok(crate::PendingSlot::insert(
4998            &self.state.pending_evals,
4999            &mut pending,
5000            id.to_string(),
5001            tx,
5002        ))
5003    }
5004
5005    /// Liveness probe. On success, returns the nonce of the page that answered (`None` for a
5006    /// page without the Victauri bridge).
5007    async fn probe_bridge(&self, webview_label: Option<&str>) -> Result<Option<String>, String> {
5008        let id = uuid::Uuid::new_v4().to_string();
5009        let (tx, rx) = tokio::sync::oneshot::channel();
5010        let slot = self.reserve_pending(&id, tx).await?;
5011        slot.bind_window(webview_label);
5012        let probe = crate::js_bridge::eval_probe_script(&id);
5013        if let Err(e) = self.bridge.eval_webview(webview_label, &probe) {
5014            return Err(format!("eval injection failed: {e}"));
5015        }
5016        if let Ok(Ok(raw)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await {
5017            Ok(crate::js_bridge::probe_answer_nonce(&raw))
5018        } else {
5019            let label = webview_label.unwrap_or("default");
5020            Err(format!(
5021                "bridge not responding on window '{label}' — the window may be hidden, \
5022                 missing the victauri capability, or the JS bridge is not loaded (e.g. the page \
5023                 failed to load: a dev-server connection-refused or blank error page has no JS \
5024                 bridge — check the window with the `screenshot` tool, which works regardless)"
5025            ))
5026        }
5027    }
5028
5029    /// Whether window `label` has shown a page other than the one an eval was armed in since
5030    /// ready-signal `seen` — and if so, the error to report. A ready signal carrying the armed
5031    /// nonce is the eval's own page announcing itself late (under load that can take seconds);
5032    /// any other is confirmed by asking the page for its CURRENT nonce, because page script can
5033    /// send a ready signal itself and must not be able to abort the agent's calls with it.
5034    ///
5035    /// Only POSITIVE evidence aborts: the page answering the probe with a nonce other than the
5036    /// armed one (its bridge's nonce is frozen, so it cannot change without a new page — and a
5037    /// page answering with no nonce where one was armed has lost the bridge that armed the
5038    /// eval). A probe that fails (a busy UI thread, a full slot map) proves nothing, so the
5039    /// eval keeps waiting — its own timeout still bounds it — and the signal is re-checked on
5040    /// the next watch tick. Aborting on a failed probe let page script (a forged signal while
5041    /// the UI is busy) cut an agent's call short and invited a retry that ran code twice.
5042    /// With no armed nonce (a page without the Victauri bridge) only a page that now HAS a
5043    /// nonce is evidence of a change.
5044    async fn page_replaced(
5045        &self,
5046        label: Option<&str>,
5047        seen: &mut u64,
5048        armed: Option<&str>,
5049    ) -> Option<String> {
5050        let label = label?;
5051        let load = self.state.page_loads.latest(label)?;
5052        if load.seq <= *seen {
5053            return None;
5054        }
5055        let previously_seen = *seen;
5056        *seen = load.seq;
5057        if armed.is_some() && load.nonce.as_deref() == armed {
5058            return None;
5059        }
5060        match self.probe_bridge(Some(label)).await {
5061            Ok(current) if current.as_deref() != armed => Some(format!(
5062                "window '{label}' loaded a new page (a reload or navigation) while the call was \
5063                 in flight, so no result will arrive. The code may or may not have run before \
5064                 the reload — check the app's state before re-running it."
5065            )),
5066            Ok(_) => None,
5067            Err(e) => {
5068                tracing::debug!(
5069                    window = label,
5070                    "ready signal not confirmed ({e}); the eval keeps waiting"
5071                );
5072                // Unconfirmed: look at this signal again on the next watch tick.
5073                *seen = previously_seen;
5074                None
5075            }
5076        }
5077    }
5078
5079    /// Drain every window into the active recording one last time before `recording stop`,
5080    /// within [`FINAL_FLUSH_BUDGET`] in total. Returns the windows that could not be read in time
5081    /// (their not-yet-drained events are missing from the stopped session).
5082    async fn final_recording_flush(&self) -> Vec<String> {
5083        let Ok(labels) = self.bridge.try_list_window_labels() else {
5084            return vec!["(window list unavailable: UI thread busy)".to_string()];
5085        };
5086        let mut pending = labels.clone();
5087        let flushed = tokio::time::timeout(FINAL_FLUSH_BUDGET, async {
5088            for label in &labels {
5089                if crate::mcp::server::drain_window_into_recording(&self.state, &self.bridge, label)
5090                    .await
5091                    .is_some()
5092                {
5093                    pending.retain(|l| l != label);
5094                }
5095            }
5096        })
5097        .await;
5098        if flushed.is_err() {
5099            tracing::debug!("recording stop: final flush ran out of time");
5100        }
5101        pending
5102    }
5103
5104    async fn eval_with_return_timeout(
5105        &self,
5106        code: &str,
5107        webview_label: Option<&str>,
5108        timeout: std::time::Duration,
5109    ) -> Result<String, String> {
5110        self.eval_outcome(code, webview_label, timeout)
5111            .await
5112            .map_err(|f| f.message)
5113    }
5114
5115    /// Run `code` in the webview and wait for its outcome; a failure says whether the code ran.
5116    #[allow(clippy::too_many_lines)]
5117    async fn eval_outcome(
5118        &self,
5119        code: &str,
5120        webview_label: Option<&str>,
5121        timeout: std::time::Duration,
5122    ) -> Result<String, EvalFailure> {
5123        use EvalFailureKind::{Aborted, NotSent, Page};
5124
5125        // The hard concurrency ceiling is enforced atomically at every reservation
5126        // (`reserve_pending`, used by both the probe and the real eval below) — NOT with a
5127        // separate early check, which races: concurrent callers would all pass a stale
5128        // `len()` read before any of them inserts. The probe is the first reservation, so a
5129        // saturated map is rejected fast (before any eval is injected) with the real "too
5130        // many concurrent" cause.
5131
5132        // Wait for the JS bridge ready signal (sent on bridge init) before
5133        // attempting evals.  For explicitly targeted windows the probe
5134        // mechanism is still used because the ready signal only proves that
5135        // *some* webview's bridge loaded — not necessarily the targeted one.
5136        if !self
5137            .state
5138            .bridge_ready
5139            .load(std::sync::atomic::Ordering::Acquire)
5140        {
5141            let notified = self.state.bridge_notify.notified();
5142            if !self
5143                .state
5144                .bridge_ready
5145                .load(std::sync::atomic::Ordering::Acquire)
5146            {
5147                let _ = tokio::time::timeout(std::time::Duration::from_secs(5), notified).await;
5148            }
5149        }
5150
5151        // Subscribe before anything is sent AND check the current value: `subscribe()` marks it
5152        // seen, so a call started after the app's exit signal would otherwise wait out its whole
5153        // timeout for a change that has already happened.
5154        let mut shutdown = self.state.shutdown_tx.subscribe();
5155        if *shutdown.borrow() {
5156            return Err(EvalFailure::new(
5157                NotSent,
5158                "the app is shutting down, so the call was not sent",
5159            ));
5160        }
5161
5162        // Reserved sentinel key for the default (unlabeled) window — cannot
5163        // collide with a real label.
5164        let label_key =
5165            webview_label.map_or_else(|| "\u{1}__default__".to_string(), str::to_string);
5166
5167        // Ready signals from here on may come from a page that replaced the one this eval runs
5168        // in; an earlier one cannot (the probe below then answers from the new page).
5169        let mut loads_seen = self.state.page_loads.current_seq();
5170
5171        // Liveness probe before EVERY eval — on the DEFAULT window as well as
5172        // labeled ones. The probe is a tiny round-trip that returns in ~ms on a
5173        // healthy bridge and fails fast (~2s) on a dead/hung/reloading one, turning
5174        // a full-timeout hang (e.g. 30s) into an immediate, clear "bridge not
5175        // responding" error. This was the #1 live-4DA friction: a webview that
5176        // reloads mid-session (HMR) made the very next tool call hang the full
5177        // timeout, and the DEFAULT window — the most common target — was never
5178        // probed at all. Probing every call (not once-cached) is what guarantees
5179        // *zero* 30s hangs even across repeated reloads; the healthy-path cost is a
5180        // single sub-millisecond localhost round-trip, negligible against the value
5181        // of never stalling an agent into a CDP fallback. It also reports the nonce of the
5182        // page the eval is armed in, which is what tells a reload from a late ready signal.
5183        let prev_timed_out = self.timed_out_labels.lock().await.remove(&label_key);
5184        let armed_nonce = match self.probe_bridge(webview_label).await {
5185            Ok(nonce) => nonce,
5186            Err(e) => {
5187                return Err(EvalFailure::new(
5188                    NotSent,
5189                    if prev_timed_out {
5190                        format!(
5191                            "{e} (a previous eval on this window also timed out — the webview \
5192                             likely reloaded or the app stopped responding)"
5193                        )
5194                    } else {
5195                        e
5196                    },
5197                ));
5198            }
5199        };
5200
5201        let id = uuid::Uuid::new_v4().to_string();
5202        let (tx, rx) = tokio::sync::oneshot::channel();
5203        let slot = self
5204            .reserve_pending(&id, tx)
5205            .await
5206            .map_err(|e| EvalFailure::new(NotSent, e))?;
5207        // Only the target window may answer (a `None` label is narrowed once it has resolved).
5208        slot.bind_window(webview_label);
5209
5210        // Auto-prepend `return` so bare expressions produce a value — but ONLY
5211        // for single expressions. Multi-statement blocks (or code containing an
5212        // explicit `return`) are used as-is. Prepending `return` to a statement
5213        // block like `foo(); return bar()` would parse as `return foo();` and
5214        // silently discard everything after the first statement (issue: core
5215        // primitive returned wrong/undefined values for "do X, then return Y").
5216        // Prepend to the code with its LEADING comments stripped: `return // note\nexpr`
5217        // parses (ASI) as `return;` and silently yields undefined.
5218        let body = strip_leading_js_comments(code.trim());
5219        let code = if should_prepend_return(body) {
5220            format!("return {body}")
5221        } else {
5222            code.trim().to_string()
5223        };
5224
5225        // Fail fast on a SYNTAX error instead of hanging for the full timeout (audit /
5226        // red-team "malformed eval consumes the full 30s"). The user code is inlined into
5227        // the wrapper; if it has a parse error the WHOLE script fails to parse, so its callback
5228        // never fires. We cannot wrap the code in `new Function`/`AsyncFunction` to surface the
5229        // SyntaxError, because dynamic code generation is gated by the same `unsafe-eval` CSP
5230        // that blocks `eval()` — which is exactly why the bridge uses an inline async-IIFE in
5231        // the first place. Instead a check script (which always parses) is delivered right
5232        // AFTER the wrapper, to the same window: webview evals run in order, and a wrapper that
5233        // parsed has already marked itself begun, so "not begun" means it did not parse. There
5234        // is no timer to race: the check used to be a 750ms watchdog armed BEFORE the code, and
5235        // a busy main thread delaying the code past it reported a parse error for code that
5236        // then ran (an `invoke_command` retried on that ran twice).
5237        // The settle logic lives in the bridge's closure-private state (see `_evalBegin` /
5238        // `_evalCheck` / `_evalSettle` in js_bridge.rs): the page can neither enumerate pending
5239        // eval ids nor suppress results, each outcome is delivered at most once, and
5240        // serialization uses a `JSON.stringify` captured before any page script ran.
5241        let inject = crate::js_bridge::eval_wrapper_script(&id, &code);
5242        let target = self
5243            .bridge
5244            .eval_webview_resolved(webview_label, &inject)
5245            .map_err(|e| EvalFailure::new(NotSent, format!("eval injection failed: {e}")))?;
5246        let deliver_to = if target.is_empty() {
5247            webview_label
5248        } else {
5249            slot.bind_window(Some(target.as_str()));
5250            Some(target.as_str())
5251        };
5252        let check = crate::js_bridge::eval_check_script(&id, armed_nonce.as_deref());
5253        // The check only runs when the page reported a nonce (see `eval_check_script`) and the
5254        // script reached it; only then can a timeout rule out a parse error.
5255        let parse_check_armed = match self.bridge.eval_webview(deliver_to, &check) {
5256            Ok(()) => armed_nonce.is_some(),
5257            Err(e) => {
5258                // Only the fast parse-error report is lost; the code itself was delivered.
5259                tracing::debug!("eval parse check not delivered: {e}");
5260                false
5261            }
5262        };
5263
5264        // While waiting, watch for the ways a call ends with NO callback ever coming: the
5265        // target window was destroyed, it loaded a new page, or the app began shutting down.
5266        // All are the EXPECTED outcome of code that closes its own window, navigates or quits
5267        // the app (e.g. invoking a `quit_app` command) — reporting them after the full timeout
5268        // as "an unresolved promise, an infinite loop…" misled agents into thinking the call
5269        // never ran.
5270        //
5271        // The window check runs in its own task: listing windows is a main-thread round trip
5272        // that can take up to ~2x its 10s dispatch timeout (about 20s) on a busy UI, and must neither stall this
5273        // wait nor push it past its deadline. A listing that FAILS (a busy or wedged UI) is not
5274        // evidence of anything — only a successful listing that lacks the window is.
5275        let watched: Option<String> = (!target.is_empty()).then_some(target);
5276        let deadline = tokio::time::Instant::now() + timeout;
5277        let mut liveness = tokio::time::interval_at(
5278            tokio::time::Instant::now() + EVAL_WINDOW_WATCH_INTERVAL,
5279            EVAL_WINDOW_WATCH_INTERVAL,
5280        );
5281        let mut check: Option<tokio::task::JoinHandle<Result<Vec<String>, String>>> = None;
5282        let armed = armed_nonce.as_deref();
5283        let mut rx = rx;
5284        // A reload that completed while the code was being delivered is already recorded.
5285        let outcome = if let Some(msg) = self
5286            .page_replaced(watched.as_deref(), &mut loads_seen, armed)
5287            .await
5288        {
5289            Err(Some(msg))
5290        } else {
5291            loop {
5292                tokio::select! {
5293                    r = &mut rx => break Ok(r),
5294                    () = self.state.page_loads.changed() => {
5295                        if let Some(msg) =
5296                            self.page_replaced(watched.as_deref(), &mut loads_seen, armed).await
5297                        {
5298                            break Err(Some(msg));
5299                        }
5300                    }
5301                    () = tokio::time::sleep_until(deadline) => break Err(None),
5302                    changed = shutdown.changed() => {
5303                        if changed.is_err() || *shutdown.borrow() {
5304                            break Err(Some(
5305                                "the app began shutting down while the call was in flight, so \
5306                                 no result will arrive. If the code/command quits or restarts the \
5307                                 app, this is the expected outcome and it most likely ran — do \
5308                                 not re-run it blindly."
5309                                    .to_string(),
5310                            ));
5311                        }
5312                    }
5313                    _ = liveness.tick(), if watched.is_some() && check.is_none() => {
5314                        // Backstop for a ready signal that arrived between select rounds.
5315                        if let Some(msg) =
5316                            self.page_replaced(watched.as_deref(), &mut loads_seen, armed).await
5317                        {
5318                            break Err(Some(msg));
5319                        }
5320                        let bridge = Arc::clone(&self.bridge);
5321                        check = Some(tokio::spawn(async move { bridge.try_list_window_labels() }));
5322                    }
5323                    listed = async { check.as_mut().expect("guarded by precondition").await },
5324                        if check.is_some() =>
5325                    {
5326                        check = None;
5327                        if let (Ok(Ok(labels)), Some(label)) = (listed, watched.as_deref())
5328                            && !labels.iter().any(|l| l == label)
5329                        {
5330                            break Err(Some(format!(
5331                                "window '{label}' was closed while the call was in flight, so no \
5332                                 result will arrive. If the code/command closes this window (or \
5333                                 quits the app), this is the expected outcome and it most likely \
5334                                 ran — do not re-run it blindly."
5335                            )));
5336                        }
5337                    }
5338                }
5339            }
5340        };
5341        if let Some(pending_check) = check {
5342            pending_check.abort();
5343        }
5344        let early = match outcome {
5345            Ok(r) => Ok(r),
5346            // The result may have landed while the page change was being confirmed.
5347            Err(Some(msg)) => match rx.try_recv() {
5348                Ok(raw) => Ok(Ok(raw)),
5349                Err(_) => return Err(EvalFailure::new(Aborted, msg)),
5350            },
5351            Err(None) => Err(()),
5352        };
5353        match early {
5354            Ok(Ok(raw)) => {
5355                self.check_bridge_version_once();
5356                if raw.len() > MAX_EVAL_RESULT_LEN {
5357                    return Err(EvalFailure::new(
5358                        Page,
5359                        format!(
5360                            "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
5361                            raw.len()
5362                        ),
5363                    ));
5364                }
5365                unwrap_eval_envelope(raw)
5366            }
5367            Ok(Err(_)) => Err(EvalFailure::new(Aborted, "eval callback channel closed")),
5368            Err(()) => {
5369                // Mark this window so the NEXT eval does a fast liveness probe —
5370                // if the bridge is gone (reloaded/crashed) the next call fails in
5371                // ~2s instead of blocking the full timeout again.
5372                self.timed_out_labels.lock().await.insert(label_key.clone());
5373                let (began, parse_note) = if parse_check_armed {
5374                    (
5375                        "the code began executing but never resolved",
5376                        "(A syntax/parse error is reported immediately, so this is NOT a parse \
5377                         error.) Common causes",
5378                    )
5379                } else {
5380                    (
5381                        "no result arrived",
5382                        "(The fast syntax-error check could not run for this call, so a parse \
5383                         error could not be ruled out — check the code's syntax.) Other causes",
5384                    )
5385                };
5386                Err(EvalFailure::new(
5387                    Aborted,
5388                    format!(
5389                        "eval timed out after {} — {began}. {parse_note}: an unresolved \
5390                         promise, an infinite loop, an `await` on something that never settles, \
5391                         or the webview reloaded / the app stopped responding mid-eval. If the \
5392                         app may have navigated or crashed, retry (the next call fails fast if \
5393                         the bridge is gone).",
5394                        format_timeout(timeout)
5395                    ),
5396                ))
5397            }
5398        }
5399    }
5400
5401    #[cfg(feature = "sqlite")]
5402    async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
5403        let roots = self.db_roots();
5404
5405        let path = if let Some(p) = db_path {
5406            Self::resolve_existing_db_path(&roots, p)?
5407        } else {
5408            // Configured db_search_paths are EXCLUSIVE when set (don't fall back to the
5409            // OS app dirs that hold WebView internals); WebView/engine internal stores are
5410            // excluded and the largest real candidate wins (audit / red-team "wrong DB").
5411            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
5412                roots.clone()
5413            } else {
5414                self.state.db_search_paths.clone()
5415            };
5416            crate::database::select_app_database(&select_dirs)?
5417        };
5418        let path_str = path
5419            .to_str()
5420            .ok_or_else(|| "invalid path encoding".to_string())?
5421            .to_string();
5422
5423        bounded::run_blocking_bounded(
5424            Some(&bounded::DB_SLOTS),
5425            "db health check",
5426            crate::database::DB_HEALTH_META_BUDGET
5427                + DB_HEALTH_COUNT_BUDGET
5428                + DB_HEALTH_CHECK_BUDGET
5429                + bounded::BLOCKING_DEADLINE_SLACK,
5430            move || {
5431                crate::database::db_health_report(
5432                    &path_str,
5433                    DB_HEALTH_COUNT_BUDGET,
5434                    DB_HEALTH_CHECK_BUDGET,
5435                )
5436            },
5437        )
5438        .await
5439    }
5440
5441    fn check_bridge_version_once(&self) {
5442        if self.bridge_checked.swap(true, Ordering::Relaxed) {
5443            return;
5444        }
5445        let handler = self.clone();
5446        tokio::spawn(async move {
5447            match handler
5448                .eval_with_return_timeout(
5449                    "window.__VICTAURI__?.version",
5450                    None,
5451                    std::time::Duration::from_secs(5),
5452                )
5453                .await
5454            {
5455                Ok(v) => {
5456                    let v = v.trim_matches('"');
5457                    if v == BRIDGE_VERSION {
5458                        tracing::debug!("Bridge version verified: {v}");
5459                    } else {
5460                        tracing::warn!(
5461                            "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
5462                        );
5463                    }
5464                }
5465                Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
5466            }
5467        });
5468    }
5469}
5470
5471const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
5472It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
5473(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
5474(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
5475\n\nBACKEND tools (direct Rust access, no webview needed): \
5476'app_info' (app config, directory paths, discovered databases, process info), \
5477'list_app_dir' (browse app data/config/log directories), \
5478'read_app_file' (read files from app directories), \
5479'query_db' (read-only SQLite queries with auto-discovery). \
5480\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
5481'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
5482capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
5483Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
5484capability/security auditing, database diagnostics, plugin state, child process enumeration, \
5485task tracking, and automatic Tauri event bus monitoring. \
5486'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
5487drops, and response corruption into Tauri commands at the Rust layer. \
5488'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
5489activity across IPC + DOM + console + network + window events into a coherent narrative. \
5490\n\nWEBVIEW tools: \
5491'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
5492'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
5493'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
5494\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
5495\n\nCOMPOUND tools with an 'action' parameter: \
5496'window' (get_state, list, manage, resize, move_to, set_title), \
5497'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
5498set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
5499get_events, events_between, get_replay, export, import, replay), \
5500'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
5501\n\nOTHER: verify_state, wait_for (incl. 'expression'/'event' conditions to await \
5502async backend work to true completion), assert_semantic, resolve_command, \
5503app_state (app-defined backend state probes), \
5504get_memory_stats, get_plugin_info, get_diagnostics.";
5505
5506impl ServerHandler for VictauriMcpHandler {
5507    fn get_info(&self) -> InitializeResult {
5508        // NOTE: we advertise `resources` (read) but NOT `resources.subscribe`. A real
5509        // server-initiated `notifications/resources/updated` push was never implemented
5510        // (subscribe/unsubscribe only record intent in memory; nothing emits updates), and
5511        // the default stateless transport has no SSE channel to push over anyway. Advertising
5512        // a subscribe capability we cannot honour misleads clients — read resources on demand.
5513        InitializeResult::new(
5514            ServerCapabilities::builder()
5515                .enable_tools()
5516                .enable_resources()
5517                .build(),
5518        )
5519        .with_instructions(SERVER_INSTRUCTIONS)
5520    }
5521
5522    async fn list_tools(
5523        &self,
5524        _request: Option<PaginatedRequestParams>,
5525        _context: RequestContext<RoleServer>,
5526    ) -> Result<ListToolsResult, ErrorData> {
5527        let all_tools = Self::tool_router().list_all();
5528        let filtered: Vec<Tool> = all_tools
5529            .into_iter()
5530            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
5531            .collect();
5532        // SEP-2549 cache hints: the tool list is fixed for the process lifetime (the
5533        // privacy config that filters it is set at plugin init), so clients may cache
5534        // it. `Private` because the list depends on this instance's privacy profile.
5535        // Legacy (< 2026-07-28) peers never see these fields — rmcp strips them.
5536        let mut result = ListToolsResult::with_all_items(filtered);
5537        result.ttl_ms = Some(LIST_RESULT_TTL_MS);
5538        result.cache_scope = Some(CacheScope::Private);
5539        Ok(result)
5540    }
5541
5542    async fn call_tool(
5543        &self,
5544        request: CallToolRequestParams,
5545        context: RequestContext<RoleServer>,
5546    ) -> Result<CallToolResponse, ErrorData> {
5547        let tool_name: String = request.name.as_ref().to_owned();
5548        // Centralized authorization: gate on the canonical `tool.action` capability
5549        // resolved from the call arguments, matching the REST path in `execute_tool`.
5550        let args_value = serde_json::Value::Object(request.arguments.clone().unwrap_or_default());
5551        // A disabled tool reports "disabled" whatever its arguments look like.
5552        if self.state.privacy.disabled_tools.contains(&tool_name) {
5553            return Ok(tool_disabled(&tool_name).into());
5554        }
5555        let capability = authz::resolve_capability(&tool_name, &args_value)
5556            .map_err(|msg| ErrorData::invalid_params(msg, None))?;
5557        if !self.state.privacy.is_call_allowed(&tool_name, &capability) {
5558            tracing::debug!(tool = %tool_name, capability = %capability, "tool call blocked by privacy config");
5559            return Ok(tool_disabled(&capability).into());
5560        }
5561        self.state
5562            .tool_invocations
5563            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5564        let start = std::time::Instant::now();
5565        tracing::debug!(tool = %tool_name, "tool invocation started");
5566        let ctx = ToolCallContext::new(self, request, context);
5567        // Same panic boundary as the REST path: without it a panicking handler left the MCP
5568        // request unanswered until the client's own timeout.
5569        let response = bounded::CatchUnwind::new(Self::tool_router().call(ctx))
5570            .await
5571            .unwrap_or_else(|panic| {
5572                tracing::error!(tool = %tool_name, "tool handler panicked: {panic}");
5573                Ok(tool_panicked(&tool_name, &panic).into())
5574            });
5575        let elapsed = start.elapsed();
5576        tracing::debug!(
5577            tool = %tool_name,
5578            elapsed_ms = elapsed.as_millis() as u64,
5579            is_error = response.as_ref().map_or(true, |r| match r {
5580                CallToolResponse::Complete(r) => r.is_error.unwrap_or(false),
5581                _ => false,
5582            }),
5583            "tool invocation completed"
5584        );
5585
5586        // Centralized output redaction: apply to all text content so no
5587        // individual tool can accidentally leak secrets. Victauri tools always
5588        // complete in one round trip, so only the `Complete` variant carries
5589        // output; MRTR intermediates (input-required / task) pass through.
5590        if self.state.privacy.redaction_enabled {
5591            response.map(|resp| match resp {
5592                CallToolResponse::Complete(mut r) => {
5593                    for item in &mut r.content {
5594                        if let ContentBlock::Text(tc) = item {
5595                            tc.text = self.state.privacy.redact_output(&tc.text);
5596                        }
5597                    }
5598                    CallToolResponse::Complete(r)
5599                }
5600                other => other,
5601            })
5602        } else {
5603            response
5604        }
5605    }
5606
5607    fn get_tool(&self, name: &str) -> Option<Tool> {
5608        if !self.state.privacy.is_tool_enabled(name) {
5609            return None;
5610        }
5611        Self::tool_router().get(name).cloned()
5612    }
5613
5614    async fn list_resources(
5615        &self,
5616        _request: Option<PaginatedRequestParams>,
5617        _context: RequestContext<RoleServer>,
5618    ) -> Result<ListResourcesResult, ErrorData> {
5619        // SEP-2549 cache hints: the resource *list* (not the contents) is static for
5620        // the process lifetime, so clients may cache it (same rationale as list_tools).
5621        let mut result = ListResourcesResult::with_all_items(vec![
5622            Resource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
5623                .with_description(
5624                    "Live IPC call log — all commands invoked between frontend and backend",
5625                )
5626                .with_mime_type("application/json"),
5627            Resource::new(RESOURCE_URI_WINDOWS, "windows")
5628                .with_description(
5629                    "Current state of all Tauri windows — position, size, visibility, focus",
5630                )
5631                .with_mime_type("application/json"),
5632            Resource::new(RESOURCE_URI_STATE, "state")
5633                .with_description(
5634                    "Victauri plugin state — event count, registered commands, memory stats",
5635                )
5636                .with_mime_type("application/json"),
5637        ]);
5638        result.ttl_ms = Some(LIST_RESULT_TTL_MS);
5639        result.cache_scope = Some(CacheScope::Private);
5640        Ok(result)
5641    }
5642
5643    async fn read_resource(
5644        &self,
5645        request: ReadResourceRequestParams,
5646        _context: RequestContext<RoleServer>,
5647    ) -> Result<ReadResourceResponse, ErrorData> {
5648        let uri = &request.uri;
5649        // Resources bypass the tool dispatcher, so they must apply the same privacy
5650        // gate themselves (audit B1): a strict profile that blocks log/window reads
5651        // as tools must not be able to read the same data via a resource.
5652        if !resource_allowed(&self.state.privacy, uri.as_str()) {
5653            return Err(ErrorData::invalid_request(
5654                format!("resource {uri} is not permitted by the current privacy configuration"),
5655                None,
5656            ));
5657        }
5658        let json = match uri.as_str() {
5659            RESOURCE_URI_IPC_LOG => {
5660                // Use the body-free, capped projection — NOT the full body-carrying
5661                // getIpcLog(). On a busy app the full log blows the eval result cap, the
5662                // eval fails, and we silently fall back to the Rust event_log (which is
5663                // itself default-window-drained) — serving a subset that looks complete.
5664                // trimmed_log_js bounds entries + truncates oversized fields so the
5665                // resource stays correct under load. (Matches the `logs ipc` tool.)
5666                let code = trimmed_log_js(
5667                    &format!("window.__VICTAURI__?.getIpcLog({DEFAULT_LOG_LIMIT})"),
5668                    DEFAULT_LOG_LIMIT,
5669                );
5670                if let Ok(json) = self.eval_with_return(&code, None).await {
5671                    json
5672                } else {
5673                    let calls = self.state.event_log.ipc_calls();
5674                    serde_json::to_string_pretty(&calls)
5675                        .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
5676                }
5677            }
5678            RESOURCE_URI_WINDOWS => {
5679                let states = self
5680                    .bridge
5681                    .try_get_window_states(None)
5682                    .map_err(|e| ErrorData::internal_error(ui_busy(&e), None))?;
5683                serde_json::to_string_pretty(&states)
5684                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
5685            }
5686            RESOURCE_URI_STATE => {
5687                let state_json = serde_json::json!({
5688                    "events_captured": self.state.event_log.len(),
5689                    "commands_registered": self.state.registry.count(),
5690                    "memory": crate::memory::current_stats(),
5691                    "port": self.state.port.load(Ordering::Relaxed),
5692                });
5693                serde_json::to_string_pretty(&state_json)
5694                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
5695            }
5696            _ => {
5697                return Err(ErrorData::resource_not_found(
5698                    format!("unknown resource: {uri}"),
5699                    None,
5700                ));
5701            }
5702        };
5703
5704        let json = if self.state.privacy.redaction_enabled {
5705            self.state.privacy.redact_output(&json)
5706        } else {
5707            json
5708        };
5709
5710        Ok(ReadResourceResult::new(vec![ResourceContents::text(json, uri)]).into())
5711    }
5712
5713    // `resources/subscribe` is legacy-protocol-only under MCP 2026-07-28 (replaced by
5714    // `subscriptions/listen`). Victauri intentionally keeps the legacy handlers: the
5715    // subscribe capability is not advertised (see `get_info`), no update push exists,
5716    // and legacy clients that call anyway get the same recorded-intent behavior as
5717    // before. `allow(deprecated)` because rmcp 3.x marks the trait methods deprecated.
5718    #[allow(deprecated)]
5719    async fn subscribe(
5720        &self,
5721        request: SubscribeRequestParams,
5722        _context: RequestContext<RoleServer>,
5723    ) -> Result<(), ErrorData> {
5724        let uri = &request.uri;
5725        // Same privacy gate as read_resource (audit B1) — don't let a blocked
5726        // resource be subscribed to for push updates.
5727        if !resource_allowed(&self.state.privacy, uri.as_str()) {
5728            return Err(ErrorData::invalid_request(
5729                format!("resource {uri} is not permitted by the current privacy configuration"),
5730                None,
5731            ));
5732        }
5733        match uri.as_str() {
5734            RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
5735                self.subscriptions.lock().await.insert(uri.clone());
5736                tracing::info!("Client subscribed to resource: {uri}");
5737                Ok(())
5738            }
5739            _ => Err(ErrorData::resource_not_found(
5740                format!("unknown resource: {uri}"),
5741                None,
5742            )),
5743        }
5744    }
5745
5746    #[allow(deprecated)]
5747    async fn unsubscribe(
5748        &self,
5749        request: UnsubscribeRequestParams,
5750        _context: RequestContext<RoleServer>,
5751    ) -> Result<(), ErrorData> {
5752        self.subscriptions.lock().await.remove(&request.uri);
5753        tracing::info!("Client unsubscribed from resource: {}", request.uri);
5754        Ok(())
5755    }
5756}
5757
5758/// JS `function trimField(v)` (with its `MB` bound): a string over [`MAX_LOG_FIELD_BYTES`]
5759/// UTF-16 units is cut with a marker, an object whose JSON is larger becomes a size marker.
5760/// The cut never falls between the halves of a surrogate pair — a lone surrogate serializes
5761/// to JSON that `serde_json` rejects, which failed the whole log read.
5762fn trim_field_js() -> String {
5763    let mb = MAX_LOG_FIELD_BYTES;
5764    format!(
5765        r"var MB = {mb};
5766            function trimField(v) {{
5767                if (typeof v === 'string') {{
5768                    if (v.length <= MB) return v;
5769                    var end = MB, c = v.charCodeAt(end - 1);
5770                    if (c >= 0xD800 && c <= 0xDBFF) end--;
5771                    return v.slice(0, end) + '…[+' + (v.length - end) + ' bytes truncated]';
5772                }}
5773                if (v && typeof v === 'object') {{
5774                    var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }}
5775                    if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }}
5776                }}
5777                return v;
5778            }}"
5779    )
5780}
5781
5782/// JS for `check_ipc_integrity`. Classifies the calls from the body-free IPC view and fetches
5783/// full entries (args + result) only for the <= 20 stale and <= 20 errored calls it lists:
5784/// deep-copying every retained body just to count statuses froze the UI thread (R5-JS1).
5785#[doc(hidden)]
5786#[must_use]
5787pub fn ipc_integrity_js(threshold_ms: i64) -> String {
5788    format!(
5789        r"return (function() {{
5790                var V = window.__VICTAURI__;
5791                var log = V?.getIpcLog(0, {{ bodies: false }}) || [];
5792                var now = Date.now();
5793                var threshold = {threshold_ms};
5794                var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
5795                var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
5796                var errored = log.filter(function(c) {{ return c.status === 'error'; }});
5797                var netCount = (V?.getNetworkLog(null, 0, {{ bodies: false }}) || []).length;
5798                var capture = (V && V.getIpcCaptureStatus) ? V.getIpcCaptureStatus() : null;
5799                var warning = null;
5800                if (capture && (capture.capture === 'none' || capture.capture === 'requests_only')) {{
5801                    warning = capture.note;
5802                }} else if (log.length === 0 && netCount > 5) {{
5803                    warning = 'Zero IPC calls captured but ' + netCount + ' network requests observed. IPC capture may not be working — check ipc_capture / get_diagnostics.';
5804                }}
5805                function withBodies(list) {{
5806                    list = list.slice(0, 20);
5807                    if (!list.length) return list;
5808                    var got = V.getIpcLog(0, {{ ids: list.map(function(c) {{ return c.id; }}) }}) || [];
5809                    var byId = {{}};
5810                    for (var i = 0; i < got.length; i++) byId[got[i].id] = got[i];
5811                    return list.map(function(c) {{ return byId[c.id] || c; }});
5812                }}
5813                // INTEGRITY = round-trip soundness: no stuck/stale (never-returned) calls.
5814                // A command that completed with an Err is a HEALTHY round-trip (it returned)
5815                // — every real app exercises error paths, so counting those as 'unhealthy'
5816                // would cry wolf. The error_count/errored_calls surface them for visibility,
5817                // but only stale calls flip `healthy`.
5818                return {{
5819                    healthy: stale.length === 0,
5820                    total_calls: log.length,
5821                    pending_count: pending.length,
5822                    stale_count: stale.length,
5823                    error_count: errored.length,
5824                    stale_calls: withBodies(stale),
5825                    errored_calls: withBodies(errored),
5826                    warning: warning,
5827                    ipc_capture: capture
5828                }};
5829            }})()"
5830    )
5831}
5832
5833/// JS for `logs slow_ipc`: ranks the calls from the body-free IPC view, then fetches full
5834/// (field-trimmed) entries only for the `limit` slowest it returns (R5-JS1).
5835#[doc(hidden)]
5836#[must_use]
5837pub fn slow_ipc_js(threshold_ms: u64, limit: usize) -> String {
5838    let trim_field = trim_field_js();
5839    format!(
5840        r"return (function() {{
5841                {trim_field}
5842                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; }}
5843                var V = window.__VICTAURI__;
5844                var log = V?.getIpcLog(0, {{ bodies: false }}) || [];
5845                var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold_ms}; }});
5846                slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
5847                var top = slow.slice(0, {limit});
5848                if (top.length) {{
5849                    var got = V.getIpcLog(0, {{ ids: top.map(function(c) {{ return c.id; }}) }}) || [];
5850                    var byId = {{}};
5851                    for (var i = 0; i < got.length; i++) byId[got[i].id] = got[i];
5852                    top = top.map(function(c) {{ return byId[c.id] || c; }});
5853                }}
5854                return {{ threshold_ms: {threshold_ms}, count: top.length, calls: top.map(trimEntry) }};
5855            }})()",
5856    )
5857}
5858
5859/// Reads the bridge's IPC capture status (`null` on a bridge without it).
5860const IPC_CAPTURE_STATUS_JS: &str = "return (window.__VICTAURI__ && typeof window.__VICTAURI__.getIpcCaptureStatus === 'function') ? window.__VICTAURI__.getIpcCaptureStatus() : null";
5861
5862/// Build a JS expression that takes an array of log entries (`source_expr`),
5863/// keeps at most `limit` of the most recent, and truncates any per-entry field
5864/// larger than [`MAX_LOG_FIELD_BYTES`]. This keeps IPC/network log results under
5865/// the eval size cap on busy apps where individual entries carry large bodies.
5866///
5867/// The returned code is a complete `return (...)` statement.
5868#[doc(hidden)]
5869#[must_use]
5870pub fn trimmed_log_js(source_expr: &str, limit: usize) -> String {
5871    let trim_field = trim_field_js();
5872    format!(
5873        r"return (function() {{
5874            {trim_field}
5875            function trimEntry(e) {{
5876                if (e == null || typeof e !== 'object') return e;
5877                var out = Array.isArray(e) ? [] : {{}};
5878                for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) out[k] = trimField(e[k]); }}
5879                return out;
5880            }}
5881            var arr = {source_expr} || [];
5882            if (arr.length > {limit}) arr = arr.slice(arr.length - {limit}); // not slice(-0): all
5883            return arr.map(trimEntry);
5884        }})()"
5885    )
5886}
5887
5888/// Wall-clock epoch milliseconds: the floor a new recording epoch's drain reads from. Taken
5889/// BEFORE the recording starts, so nothing logged after the start falls below it. (An imported
5890/// session's old start time used to pull in the page's whole history.)
5891fn now_ms() -> f64 {
5892    #[allow(clippy::cast_precision_loss)]
5893    let ms = chrono::Utc::now().timestamp_millis() as f64;
5894    ms
5895}
5896
5897/// Unwrap the `{"__victauri_ok": <val>, "__victauri_type": <t>}` (or
5898/// `{"__victauri_err": <msg>}`) envelope produced by the eval bridge into the
5899/// value/error string returned to callers.
5900///
5901/// Parsing uses `serde_json`'s default recursion limit (it is intentionally NOT
5902/// disabled — an unbounded recursive parse of a pathologically deep result
5903/// overflows the worker thread stack and crashes the host). When the parse
5904/// fails because the value is too deeply nested, the envelope is stripped by
5905/// string slicing (no recursion) so the actual value is still returned rather
5906/// than leaking the raw envelope string.
5907fn unwrap_eval_envelope(raw: String) -> Result<String, EvalFailure> {
5908    // Page JSON: sanitize lone surrogates first, or one truncated emoji in a result sends it
5909    // down the raw-string fallback below (and every consumer's own parse of it fails too).
5910    if let Ok(envelope) = page_json::parse_page_json::<serde_json::Value>(&raw) {
5911        if let Some(why) = envelope.get("__victauri_not_run") {
5912            return Err(EvalFailure::new(
5913                EvalFailureKind::NotSent,
5914                format!(
5915                    "{PARSE_ERROR_PREFIX} {}",
5916                    why.as_str().unwrap_or("the code did not begin executing")
5917                ),
5918            ));
5919        }
5920        if let Some(err) = envelope.get("__victauri_err") {
5921            return Err(EvalFailure::new(
5922                EvalFailureKind::Page,
5923                format!(
5924                    "JavaScript error: {}",
5925                    err.as_str().unwrap_or("unknown error")
5926                ),
5927            ));
5928        }
5929        if let Some(why) = envelope.get("__victauri_unserializable") {
5930            return Err(EvalFailure::new(
5931                EvalFailureKind::Page,
5932                format!(
5933                    "the code ran, but its result could not be serialized to JSON ({}). Return a JSON-serializable value instead — e.g. String() a BigInt, or pick the fields you need from a circular object.",
5934                    why.as_str().unwrap_or("unknown reason")
5935                ),
5936            ));
5937        }
5938        if envelope.get("__victauri_ok").is_some() {
5939            let js_type = envelope
5940                .get("__victauri_type")
5941                .and_then(|t| t.as_str())
5942                .unwrap_or("value");
5943            return match js_type {
5944                "undefined" => Ok("undefined".to_string()),
5945                "null" => Ok("null".to_string()),
5946                _ => Ok(serde_json::to_string(&envelope["__victauri_ok"])
5947                    .unwrap_or_else(|_| "null".to_string())),
5948            };
5949        }
5950    }
5951    // Fallback for results too deeply nested for the recursion-limited parser. `rfind` is
5952    // correct here: the wrapper appends `,"__victauri_type":"<type>"}` AFTER the entire payload,
5953    // so the real delimiter is structurally the LAST occurrence — a nested object key of the same
5954    // name appears earlier, and inside a string payload the quotes are escaped (`\"`), so neither
5955    // can be the last match. A hostile page therefore can't shift the slice boundary.
5956    if let Some(after) = raw.strip_prefix(r#"{"__victauri_ok":"#)
5957        && let Some(idx) = after.rfind(r#","__victauri_type":"#)
5958    {
5959        return Ok(after[..idx].to_string());
5960    }
5961    if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) {
5962        let msg = after.trim_end_matches('}').trim_matches('"');
5963        return Err(EvalFailure::new(
5964            EvalFailureKind::Page,
5965            format!("JavaScript error: {msg}"),
5966        ));
5967    }
5968    Ok(raw)
5969}
5970
5971/// The eval code for a caller-supplied EXPRESSION (`verify_state`, `assert_semantic`,
5972/// `wait_for` expression): code that returns the expression's value.
5973///
5974/// These used to be wrapped as `return (<expr>)`, so a trailing `;` or a trailing `// comment`
5975/// (which swallowed the closing paren) turned a valid expression into a parse error. A single
5976/// expression is now handed to the eval engine as-is: its string/comment-aware `return`
5977/// auto-prepend already accepts a trailing `;`, trailing comments and multi-line expressions,
5978/// and the wrapper ends the code with a newline. Anything the engine would NOT wrap (an object
5979/// literal `{a: 1}.a`, which reads as a block) keeps the parenthesized form, with trailing `;`s
5980/// dropped and the expression on lines of its own so a trailing line comment cannot swallow the
5981/// closing paren. Multi-statement code stays a syntax error (these tools take an expression).
5982fn expression_eval_code(expr: &str) -> String {
5983    if should_prepend_return(expr) {
5984        return expr.to_string();
5985    }
5986    let body = expr.trim_end_matches(|c: char| c == ';' || is_js_space(c));
5987    format!("return (\n{body}\n);")
5988}
5989
5990/// Parse the value of an expression evaluated by [`expression_eval_code`]. `undefined` (which
5991/// the engine reports as the bare text `undefined`) is read as `null`: JSON has no `undefined`,
5992/// and `null` is what JavaScript's `x == null` groups it with — so `exists` is false and `falsy`
5993/// is true for it instead of the call failing as "not valid JSON".
5994fn parse_expression_value(raw: &str) -> Result<serde_json::Value, serde_json::Error> {
5995    if raw == "undefined" {
5996        return Ok(serde_json::Value::Null);
5997    }
5998    serde_json::from_str(raw)
5999}
6000
6001/// The failure a page action reported, as tool-error text: `{ok: false, …}`, or an object with
6002/// a top-level `error` string and no `ok: true`. Anything else (arrays with per-item errors, an
6003/// audit whose findings contain an `error` field) is a result. The page's recovery hint is kept
6004/// in the same `[hint: …]` form every tool error uses; it is page-controlled, so only a plain
6005/// `UPPER_SNAKE` word is carried over.
6006fn page_action_error(result: &str) -> Option<String> {
6007    let value: serde_json::Value = page_json::parse_page_json(result).ok()?;
6008    let obj = value.as_object()?;
6009    let ok = obj.get("ok").and_then(serde_json::Value::as_bool);
6010    let error = obj.get("error").and_then(serde_json::Value::as_str);
6011    if ok == Some(true) || (ok.is_none() && error.is_none()) {
6012        return None;
6013    }
6014    let mut message = error.map_or_else(
6015        || format!("the page reported failure: {value}"),
6016        str::to_string,
6017    );
6018    if let Some(hint) = obj.get("hint").and_then(serde_json::Value::as_str)
6019        && !hint.is_empty()
6020        && hint.len() <= 32
6021        && hint.bytes().all(|b| b.is_ascii_uppercase() || b == b'_')
6022    {
6023        message.push_str(&format!("\n\n[hint: {hint}]"));
6024    }
6025    Some(message)
6026}
6027
6028/// Statement keywords where a leading `return` would be a syntax error. Matched as whole words
6029/// (followed by any non-identifier byte — `if\t(`, `const\n`, `function*` — or the end).
6030const STMT_KEYWORDS: &[&str] = &[
6031    "return", "if", "for", "while", "switch", "try", "const", "let", "var", "function", "class",
6032    "throw", "do", "debugger", "with",
6033];
6034
6035/// `code` starts with the whole word `word` (not merely a longer identifier sharing its prefix).
6036fn starts_with_word(code: &str, word: &str) -> bool {
6037    code.starts_with(word)
6038        && code[word.len()..]
6039            .chars()
6040            .next()
6041            .is_none_or(|c| !is_js_ident_char(c))
6042}
6043
6044/// Does `code` begin with a statement (not an expression) — a statement keyword, a block, an
6045/// `async function` declaration, or a label (`outer: for …`)? Prepending `return` to any of
6046/// these is a syntax error or changes its meaning.
6047fn starts_with_statement(code: &str) -> bool {
6048    if code.starts_with('{') || STMT_KEYWORDS.iter().any(|k| starts_with_word(code, k)) {
6049        return true;
6050    }
6051    if starts_with_word(code, "async") && starts_with_word(code[5..].trim_start(), "function") {
6052        return true;
6053    }
6054    // A label: an identifier followed (after optional whitespace) by a single `:`.
6055    let ident_len: usize = code
6056        .chars()
6057        .take_while(|&c| is_js_ident_char(c))
6058        .map(char::len_utf8)
6059        .sum();
6060    ident_len > 0
6061        && !code.as_bytes()[0].is_ascii_digit()
6062        && code[ident_len..].trim_start().starts_with(':')
6063}
6064
6065/// Blocking: open `path`, refuse it unless the OPENED file is a regular file (the handler's
6066/// earlier checks ran on the path, which can be swapped for a FIFO or device before the open),
6067/// then read at most `max_bytes + 1` bytes (audit B7: never the whole file; the `+1` detects
6068/// truncation). Returns the bytes, the file's size and its modification time (Unix seconds).
6069pub(crate) fn read_regular_file(
6070    path: &std::path::Path,
6071    max_bytes: usize,
6072) -> Result<(Vec<u8>, usize, Option<u64>), String> {
6073    use std::io::Read;
6074    let f = std::fs::File::open(path).map_err(|e| e.to_string())?;
6075    let metadata = f.metadata().map_err(|e| e.to_string())?;
6076    if !metadata.is_file() {
6077        return Err("not a regular file".to_string());
6078    }
6079    let modified = metadata.modified().ok().map(|t| {
6080        t.duration_since(std::time::SystemTime::UNIX_EPOCH)
6081            .unwrap_or_default()
6082            .as_secs()
6083    });
6084    let mut buf = Vec::new();
6085    f.take(max_bytes as u64 + 1)
6086        .read_to_end(&mut buf)
6087        .map_err(|e| e.to_string())?;
6088    let size = usize::try_from(metadata.len()).unwrap_or(usize::MAX);
6089    Ok((buf, size, modified))
6090}
6091
6092/// Resolve `.` and `..` components without touching the filesystem.
6093#[cfg(feature = "sqlite")]
6094fn lexically_normalize(path: &std::path::Path) -> std::path::PathBuf {
6095    let mut out = std::path::PathBuf::new();
6096    for component in path.components() {
6097        match component {
6098            std::path::Component::CurDir => {}
6099            std::path::Component::ParentDir => {
6100                out.pop();
6101            }
6102            other => out.push(other),
6103        }
6104    }
6105    out
6106}
6107
6108/// A line ending in one of these continues onto the next line (no ASI).
6109const ASI_CONTINUES_AFTER: &[u8] = b"+-*/%&|^!=<>?:,.([{~";
6110/// A line starting with one of these continues the previous line (no ASI) — including `(`,
6111/// `[` and a template literal, which JavaScript itself treats as a continuation.
6112const ASI_CONTINUES_BEFORE: &[u8] = b".?)]}+-*/%&|^=<>,:([`";
6113
6114/// Length of the JavaScript line terminator at byte `i` — LF, CR, CRLF (2), or U+2028 /
6115/// U+2029 (3) — or 0 when there is none there.
6116fn line_terminator_len(bytes: &[u8], i: usize) -> usize {
6117    match bytes.get(i) {
6118        Some(b'\n') => 1,
6119        Some(b'\r') => 1 + usize::from(bytes.get(i + 1) == Some(&b'\n')),
6120        Some(0xE2)
6121            if bytes.get(i + 1) == Some(&0x80) && matches!(bytes.get(i + 2), Some(0xA8 | 0xA9)) =>
6122        {
6123            3
6124        }
6125        _ => 0,
6126    }
6127}
6128
6129/// Index of the first line terminator at or after byte `from`, if any.
6130fn find_line_terminator(bytes: &[u8], from: usize) -> Option<usize> {
6131    (from..bytes.len()).find(|&i| line_terminator_len(bytes, i) > 0)
6132}
6133
6134/// `code` after the rest of its current line (and that line's terminator); `""` if none.
6135fn after_line(code: &str) -> &str {
6136    let bytes = code.as_bytes();
6137    find_line_terminator(bytes, 0).map_or("", |n| &code[n + line_terminator_len(bytes, n)..])
6138}
6139
6140/// Strip leading whitespace and comments: `//`, `/* */`, and the HTML-like `<!--` / `-->`
6141/// line comments (the code starts a line). Every JavaScript line terminator ends a line
6142/// comment — CR and U+2028/U+2029 as well as LF.
6143fn strip_leading_js_comments(mut code: &str) -> &str {
6144    loop {
6145        code = code.trim_start();
6146        if let Some(rest) = code
6147            .strip_prefix("//")
6148            .or_else(|| code.strip_prefix("<!--"))
6149            .or_else(|| code.strip_prefix("-->"))
6150        {
6151            code = after_line(rest);
6152        } else if let Some(rest) = code.strip_prefix("/*") {
6153            code = rest.find("*/").map_or("", |n| &rest[n + 2..]);
6154        } else {
6155            return code;
6156        }
6157    }
6158}
6159
6160/// What opened a bracket, for [`should_prepend_return`]: a `/` right after the `)` of an
6161/// `if`/`while`/`for`/`with` head starts a regex (the statement body), not a division, and `of`
6162/// is a keyword only directly inside a `for (…)` head.
6163#[derive(PartialEq, Clone, Copy)]
6164enum Bracket {
6165    Plain,
6166    ControlHead,
6167    ForHead,
6168}
6169
6170/// String/template/comment scan state for [`should_prepend_return`].
6171#[derive(PartialEq, Clone, Copy)]
6172enum ScanState {
6173    Code,
6174    SingleQuote,
6175    DoubleQuote,
6176    Template,
6177}
6178
6179/// Decide whether to wrap `code` with a leading `return`.
6180///
6181/// Only a single bare expression should get `return` prepended. Code that is a
6182/// multi-statement block, contains an explicit top-level `return`, or starts
6183/// with a statement keyword is used as-is — prepending `return` to such code
6184/// would execute only the first statement and silently discard the rest.
6185///
6186/// The scan is string/template/comment-aware and only treats a `;` or an
6187/// explicit `return` token as significant when it occurs at bracket depth 0
6188/// outside of any string, template literal, or comment.
6189fn should_prepend_return(code: &str) -> bool {
6190    use ScanState::{Code, DoubleQuote, SingleQuote, Template};
6191
6192    let code = strip_leading_js_comments(code.trim());
6193    if code.is_empty() || starts_with_statement(code) {
6194        return false;
6195    }
6196
6197    let bytes = code.as_bytes();
6198    let mut i = 0;
6199    let mut depth: i32 = 0;
6200    let mut state = ScanState::Code;
6201    // Depths at which a template literal's `${` substitution opened: the matching `}` resumes
6202    // the template (else a backtick inside `${'`'}` was read as the template's end).
6203    let mut template_depths: Vec<i32> = Vec::new();
6204    // Only whitespace since the last line terminator (an HTML-like `-->` comment position).
6205    let mut at_line_start = true;
6206    // Every open bracket (and `${`) and what opened it; the index of the last `)` that closed
6207    // an `if`/`while`/`for`/`with` head.
6208    let mut brackets: Vec<Bracket> = Vec::new();
6209    let mut control_head_closed_at: Option<usize> = None;
6210
6211    // Is there a top-level `return` token starting at byte `i` (word-bounded, and not a
6212    // property name such as `obj.return`)?
6213    let is_return_token = |i: usize| -> bool {
6214        let prev_ok = code[..i]
6215            .chars()
6216            .next_back()
6217            .is_none_or(|c| !is_js_ident_char(c));
6218        prev_ok
6219            && code[i..].starts_with("return")
6220            && code[i + 6..]
6221                .chars()
6222                .next()
6223                .is_none_or(|c| !is_js_ident_char(c))
6224            && !preceded_by_dot(code, i)
6225    };
6226
6227    // The last two significant (non-whitespace, non-comment) bytes seen in code, and where the
6228    // last one is — for the ASI and regex-vs-division decisions.
6229    let mut last_sig: Option<u8> = None;
6230    let mut prev_sig: Option<u8> = None;
6231    let mut last_sig_idx = 0usize;
6232
6233    while i < bytes.len() {
6234        let c = bytes[i];
6235        // LF, CR, CRLF and U+2028 / U+2029 are all JavaScript line terminators.
6236        let terminator = line_terminator_len(bytes, i);
6237        if state == Code && terminator > 0 {
6238            let next_start = i + terminator;
6239            if depth <= 0 && asi_ends_statement(code, next_start, last_sig, prev_sig, last_sig_idx)
6240            {
6241                return false;
6242            }
6243            at_line_start = true;
6244            i = next_start;
6245            continue;
6246        }
6247        match state {
6248            Code => {
6249                // HTML-like comments (Script goal): `<!--` anywhere, and `-->` at the start of
6250                // a line, comment out the rest of the line — up to ANY line terminator.
6251                let line_comment = bytes[i..].starts_with(b"<!--")
6252                    || (at_line_start && bytes[i..].starts_with(b"-->"))
6253                    || bytes[i..].starts_with(b"//");
6254                if line_comment {
6255                    i = find_line_terminator(bytes, i).unwrap_or(bytes.len());
6256                    continue;
6257                }
6258                // A non-ASCII character: JavaScript whitespace (NBSP, BOM, …) is skipped like
6259                // any whitespace; anything else is read as part of an identifier (`énew` is a
6260                // name, not the keyword `new`). Line terminators were handled above.
6261                if c >= 0x80 {
6262                    let ch = code.get(i..).and_then(|rest| rest.chars().next());
6263                    let len = ch.map_or(1, char::len_utf8);
6264                    if !ch.is_some_and(is_js_space) {
6265                        at_line_start = false;
6266                        prev_sig = last_sig;
6267                        last_sig = Some(c);
6268                        last_sig_idx = i + len - 1;
6269                    }
6270                    i += len;
6271                    continue;
6272                }
6273                if !c.is_ascii_whitespace() {
6274                    at_line_start = false;
6275                }
6276                let in_for_head = brackets.last() == Some(&Bracket::ForHead);
6277                match c {
6278                    b'\'' => state = SingleQuote,
6279                    b'"' => state = DoubleQuote,
6280                    b'`' => state = Template,
6281                    b'/' if bytes.get(i + 1) == Some(&b'*') => {
6282                        let body_start = i + 2;
6283                        let end = code[body_start..]
6284                            .find("*/")
6285                            .map_or(bytes.len(), |n| body_start + n);
6286                        i = (end + 2).min(bytes.len());
6287                        // A block comment spanning a line break IS a line break for ASI:
6288                        // `a = 1 /*\n*/ b = 2` is two statements.
6289                        if find_line_terminator(&bytes[..end], body_start).is_some() {
6290                            if depth <= 0
6291                                && asi_ends_statement(code, i, last_sig, prev_sig, last_sig_idx)
6292                            {
6293                                return false;
6294                            }
6295                            at_line_start = true;
6296                        }
6297                        continue;
6298                    }
6299                    // After `}` a `/` is division if the brace closed an object literal, and a
6300                    // regex if it closed a block — undecidable here, so run the code as-is.
6301                    b'/' if last_sig == Some(b'}') => return false,
6302                    b'/' if (last_sig == Some(b')')
6303                        && control_head_closed_at == Some(last_sig_idx))
6304                        || slash_starts_regex(
6305                            code,
6306                            last_sig,
6307                            prev_sig,
6308                            last_sig_idx,
6309                            in_for_head,
6310                        ) =>
6311                    {
6312                        // A regex literal: skip it whole (a quote or newline-like character
6313                        // inside it must not be read as code), then its flags.
6314                        i = skip_regex_literal(bytes, i);
6315                        prev_sig = last_sig;
6316                        last_sig = Some(b')'); // an operand, like a closed group
6317                        last_sig_idx = i.saturating_sub(1);
6318                        continue;
6319                    }
6320                    b'(' | b'[' | b'{' => {
6321                        depth += 1;
6322                        let head = if c == b'(' {
6323                            control_head_kind(code, last_sig, last_sig_idx)
6324                        } else {
6325                            Bracket::Plain
6326                        };
6327                        brackets.push(head);
6328                    }
6329                    b')' | b']' | b'}' => {
6330                        depth -= 1;
6331                        if brackets.pop().is_some_and(|b| b != Bracket::Plain) && c == b')' {
6332                            control_head_closed_at = Some(i);
6333                        }
6334                        if c == b'}' && template_depths.last() == Some(&depth) {
6335                            template_depths.pop();
6336                            state = Template;
6337                        }
6338                    }
6339                    // A top-level `;` with more CODE after it (not just a comment) is a
6340                    // multi-statement block.
6341                    b';' if depth <= 0 && !strip_leading_js_comments(&code[i + 1..]).is_empty() => {
6342                        return false;
6343                    }
6344                    // An explicit top-level `return` token means the code already returns.
6345                    b'r' if depth <= 0 && is_return_token(i) => return false,
6346                    _ => {}
6347                }
6348                if !c.is_ascii_whitespace() {
6349                    prev_sig = last_sig;
6350                    last_sig = Some(c);
6351                    last_sig_idx = i;
6352                }
6353            }
6354            SingleQuote | DoubleQuote | Template => {
6355                let close = match state {
6356                    SingleQuote => b'\'',
6357                    DoubleQuote => b'"',
6358                    _ => b'`',
6359                };
6360                if c == b'\\' {
6361                    i += 1;
6362                } else if state == Template && c == b'$' && bytes.get(i + 1) == Some(&b'{') {
6363                    template_depths.push(depth);
6364                    brackets.push(Bracket::Plain);
6365                    depth += 1;
6366                    state = Code;
6367                    i += 1;
6368                } else if c == close {
6369                    state = Code;
6370                    prev_sig = last_sig;
6371                    last_sig = Some(c);
6372                    last_sig_idx = i;
6373                }
6374            }
6375        }
6376        i += 1;
6377    }
6378
6379    true
6380}
6381
6382/// An identifier byte: ASCII letter/digit/`_`/`$`, or any byte of a non-ASCII character (the
6383/// scanner steps over non-ASCII whitespace itself, so what remains is part of a name).
6384fn is_js_ident(b: u8) -> bool {
6385    b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || b >= 0x80
6386}
6387
6388/// JavaScript whitespace outside ASCII: the Unicode space separators (NBSP, U+2000…), and BOM.
6389fn is_js_space(c: char) -> bool {
6390    c.is_whitespace() || c == '\u{feff}'
6391}
6392
6393/// A character that can be part of an identifier (approximately: non-ASCII letters are not
6394/// told apart from other non-ASCII symbols, which are syntax errors anyway).
6395fn is_js_ident_char(c: char) -> bool {
6396    if c.is_ascii() {
6397        c.is_ascii_alphanumeric() || c == '_' || c == '$'
6398    } else {
6399        !is_js_space(c) && !matches!(c, '\u{2028}' | '\u{2029}')
6400    }
6401}
6402
6403/// Start of the identifier that ends just before byte `end` (a char boundary); `end` if none.
6404fn ident_start_before(code: &str, end: usize) -> usize {
6405    let Some(head) = code.get(..end) else {
6406        return end;
6407    };
6408    let mut start = end;
6409    for (idx, c) in head.char_indices().rev() {
6410        if !is_js_ident_char(c) {
6411            break;
6412        }
6413        start = idx;
6414    }
6415    start
6416}
6417
6418/// What a `(` opens, given the last significant token before it: an `if`/`while`/`with` head,
6419/// a `for` (or `for await`) head, or anything else.
6420fn control_head_kind(code: &str, last_sig: Option<u8>, last_sig_idx: usize) -> Bracket {
6421    if !last_sig.is_some_and(is_js_ident) {
6422        return Bracket::Plain;
6423    }
6424    match js_keyword_ending_at(code, last_sig_idx) {
6425        "if" | "while" | "with" => Bracket::ControlHead,
6426        "for" => Bracket::ForHead,
6427        "await" => {
6428            // `for await (`: the word before `await`.
6429            let before = code[..=last_sig_idx]
6430                .strip_suffix("await")
6431                .unwrap_or("")
6432                .trim_end();
6433            match before.len().checked_sub(1) {
6434                Some(end) if js_keyword_ending_at(code, end) == "for" => Bracket::ForHead,
6435                _ => Bracket::Plain,
6436            }
6437        }
6438        _ => Bracket::Plain,
6439    }
6440}
6441
6442/// Is the token starting at byte `start` a property name (`obj.of`, `a?.return`)? Such a
6443/// word is never a keyword.
6444fn preceded_by_dot(code: &str, start: usize) -> bool {
6445    code[..start].trim_end().ends_with('.')
6446}
6447
6448/// The KEYWORD candidate that ends at byte `end` (inclusive): the identifier there, or `""`
6449/// when `code[end]` is not an identifier byte or the word is a property name after `.`.
6450fn js_keyword_ending_at(code: &str, end: usize) -> &str {
6451    let bytes = code.as_bytes();
6452    if !bytes.get(end).copied().is_some_and(is_js_ident) || !code.is_char_boundary(end + 1) {
6453        return "";
6454    }
6455    let start = ident_start_before(code, end + 1);
6456    if start > end || preceded_by_dot(code, start) {
6457        return "";
6458    }
6459    &code[start..=end]
6460}
6461
6462/// Keywords after which an expression (not a statement end) must follow.
6463///
6464/// NOT listed: `yield`, which is a plain identifier inside the async-arrow wrapper eval code
6465/// runs in (it is a keyword only in generators and strict code), and `of`, which is a keyword
6466/// only inside a `for (… of …)` head — see [`is_expr_keyword`].
6467const EXPR_KEYWORDS: &[&str] = &[
6468    "instanceof",
6469    "in",
6470    "typeof",
6471    "void",
6472    "delete",
6473    "new",
6474    "await",
6475    "return",
6476    "case",
6477    "do",
6478    "else",
6479    "throw",
6480];
6481
6482/// Whether `word` is a keyword after which an operand must follow. `of` is one only directly
6483/// inside a `for (…)` head (`in_for_head`); anywhere else it is an identifier (`f(of / 2)`, or
6484/// `of` then a new line then `foo()`, which is two statements).
6485fn is_expr_keyword(word: &str, in_for_head: bool) -> bool {
6486    (word == "of" && in_for_head) || EXPR_KEYWORDS.contains(&word)
6487}
6488
6489/// Is the `.` at byte `dot` the end of a numeric literal (`1.`) — a complete operand — rather
6490/// than a member access? Only a plain decimal integer (digits and `_`, not itself after a `.`)
6491/// qualifies: `a1.`, `x.`, `0x1.`, `1e3.`, `1n.` and `1.5.` are member accesses. A misread
6492/// only matters in one direction: reading a member access as a complete number merely skips
6493/// the `return` prepend, which is always safe.
6494fn dot_completes_number(code: &str, dot: usize) -> bool {
6495    let bytes = code.as_bytes();
6496    let start = ident_start_before(code, dot);
6497    let word = &bytes[start..dot];
6498    word.first().is_some_and(u8::is_ascii_digit)
6499        && word.iter().all(|b| b.is_ascii_digit() || *b == b'_')
6500        && !(start > 0 && bytes[start - 1] == b'.')
6501}
6502
6503/// A line (or operand) ending in POSTFIX `++`/`--`.
6504fn ends_in_postfix(last_sig: Option<u8>, prev_sig: Option<u8>) -> bool {
6505    matches!(
6506        (prev_sig, last_sig),
6507        (Some(b'+'), Some(b'+')) | (Some(b'-'), Some(b'-'))
6508    )
6509}
6510
6511/// Does a line break just before `next_start` end the statement (JavaScript ASI)? Only
6512/// consulted at bracket depth 0.
6513fn asi_ends_statement(
6514    code: &str,
6515    next_start: usize,
6516    last_sig: Option<u8>,
6517    prev_sig: Option<u8>,
6518    last_sig_idx: usize,
6519) -> bool {
6520    let rest = strip_leading_js_comments(&code[next_start.min(code.len())..]);
6521    let Some(&next) = rest.as_bytes().first() else {
6522        return false; // nothing follows
6523    };
6524    // Restricted production: `a\n++b` is `a; ++b`.
6525    if rest.starts_with("++") || rest.starts_with("--") {
6526        return true;
6527    }
6528    // After POSTFIX `++`/`--` nothing but a binary operator can continue the expression, and
6529    // `i++\n[…]` / `i++\n(…)` are ASI'd into two statements — treat any following line as a
6530    // new statement (not prepending is always safe; prepending would drop that line).
6531    if ends_in_postfix(last_sig, prev_sig) {
6532        return true;
6533    }
6534    // A line ending in `1.` ends in a complete number, not a member access: `1.` then a new
6535    // line then `f()` is two statements.
6536    let number_dot = last_sig == Some(b'.') && dot_completes_number(code, last_sig_idx);
6537    let continues_after = (!number_dot
6538        && last_sig.is_some_and(|p| ASI_CONTINUES_AFTER.contains(&p)))
6539        || is_expr_keyword(js_keyword_ending_at(code, last_sig_idx), false);
6540    !(continues_after || ASI_CONTINUES_BEFORE.contains(&next))
6541}
6542
6543/// Whether a `/` (not starting a comment) begins a regex literal rather than division:
6544/// true where an operand is expected — at the start, after an operator or opening
6545/// punctuation, or after a keyword such as `return`/`typeof`. After a postfix `++`/`--`
6546/// (a complete operand) it is division. (After the `)` of a control-statement head it is a
6547/// regex too; the caller decides that case.)
6548fn slash_starts_regex(
6549    code: &str,
6550    last_sig: Option<u8>,
6551    prev_sig: Option<u8>,
6552    last_sig_idx: usize,
6553    in_for_head: bool,
6554) -> bool {
6555    if ends_in_postfix(last_sig, prev_sig) {
6556        return false;
6557    }
6558    match last_sig {
6559        None => true,
6560        Some(b) if b"(,=:[!&|?{};+-*%<>~^".contains(&b) => true,
6561        Some(b) if is_js_ident(b) => {
6562            is_expr_keyword(js_keyword_ending_at(code, last_sig_idx), in_for_head)
6563        }
6564        Some(_) => false,
6565    }
6566}
6567
6568/// Skip a regex literal starting at the `/` at `start`; returns the index after its flags.
6569fn skip_regex_literal(bytes: &[u8], start: usize) -> usize {
6570    let mut i = start + 1;
6571    let mut in_class = false;
6572    while i < bytes.len() {
6573        match bytes[i] {
6574            b'\\' => i += 1,
6575            b'[' => in_class = true,
6576            b']' => in_class = false,
6577            b'/' if !in_class => break,
6578            // Unterminated at a line terminator (any of them): let it be scanned normally.
6579            _ if line_terminator_len(bytes, i) > 0 => return i,
6580            _ => {}
6581        }
6582        i += 1;
6583    }
6584    i += 1;
6585    while i < bytes.len() && is_js_ident(bytes[i]) {
6586        i += 1;
6587    }
6588    i
6589}
6590
6591#[cfg(test)]
6592mod prop_tests {
6593    //! Property-based tests for the eval auto-return heuristic — the code that
6594    //! caused the worst bug in the system (silent corruption of multi-statement
6595    //! eval) and has bitten twice. These generate many JS-ish snippets and
6596    //! assert the invariants that keep eval correct.
6597    use super::should_prepend_return;
6598    use proptest::prelude::*;
6599
6600    /// A small set of non-keyword identifier-ish expressions.
6601    fn ident() -> impl Strategy<Value = String> {
6602        prop_oneof![
6603            Just("a".to_string()),
6604            Just("x".to_string()),
6605            Just("foo".to_string()),
6606            Just("window.x".to_string()),
6607            Just("document.title".to_string()),
6608            Just("obj.prop".to_string()),
6609            Just("arr[0]".to_string()),
6610            Just("localStorage".to_string()),
6611        ]
6612    }
6613
6614    /// A single bare expression: never starts with a statement keyword, has no
6615    /// top-level `;`, and contains no `return`.
6616    fn bare_expr() -> impl Strategy<Value = String> {
6617        prop_oneof![
6618            ident(),
6619            (ident(), ident()).prop_map(|(a, b)| format!("{a} + {b}")),
6620            (ident(), ident()).prop_map(|(a, b)| format!("{a}({b})")),
6621            ident().prop_map(|a| format!("{a}.length")),
6622            any::<u16>().prop_map(|n| n.to_string()),
6623        ]
6624    }
6625
6626    proptest! {
6627        /// Must never panic or hang on ANY input — including malformed code,
6628        /// unbalanced quotes, and arbitrary unicode (the scanner indexes bytes).
6629        #[test]
6630        fn never_panics_on_arbitrary_input(s in ".{0,256}") {
6631            let _ = should_prepend_return(&s);
6632        }
6633
6634        /// A single bare expression is safe to wrap with `return` → true.
6635        #[test]
6636        fn bare_expressions_are_prepended(e in bare_expr()) {
6637            prop_assert!(should_prepend_return(&e), "bare expr not prepended: {e:?}");
6638        }
6639
6640        /// THE critical bug class: `<expr>; return <expr>` must NOT be prepended
6641        /// (else `return <expr>;` runs and the rest is silently discarded).
6642        #[test]
6643        fn semicolon_multistatement_with_return_never_prepended(
6644            setup in bare_expr(), ret in bare_expr()
6645        ) {
6646            let code = format!("{setup}; return {ret}");
6647            prop_assert!(!should_prepend_return(&code), "would corrupt: {code:?}");
6648        }
6649
6650        /// Newline-separated (ASI) explicit return must also be left as-is.
6651        #[test]
6652        fn newline_explicit_return_never_prepended(pre in bare_expr(), ret in bare_expr()) {
6653            let code = format!("{pre}\nreturn {ret}");
6654            prop_assert!(!should_prepend_return(&code), "explicit return prepended: {code:?}");
6655        }
6656
6657        /// A line after a postfix `++`/`--`, or after a keyword-named PROPERTY (`obj.of`,
6658        /// `obj.in`), starts a new statement: prepending would silently drop it (audit V-6).
6659        #[test]
6660        fn statement_after_postfix_or_keyword_property_never_prepended(
6661            a in ident(), b in bare_expr(), op in prop_oneof![Just("++"), Just("--")],
6662            open in prop_oneof![Just("["), Just("(")], prop in prop_oneof![Just("of"), Just("in")]
6663        ) {
6664            let close = if open == "[" { "]" } else { ")" };
6665            let postfix = format!("{a}{op}\n{open}{b}{close}");
6666            prop_assert!(!should_prepend_return(&postfix), "would drop a line: {postfix:?}");
6667            let keyword_prop = format!("{a}.{prop}\n{b}");
6668            prop_assert!(!should_prepend_return(&keyword_prop), "would drop a line: {keyword_prop:?}");
6669        }
6670
6671        /// `;` or the word `return` INSIDE a string literal must not trigger a
6672        /// false multi-statement split — a bare string is one expression.
6673        #[test]
6674        fn semicolons_and_return_inside_strings_are_ignored(inner in "[a-z0-9;= ]{0,24}") {
6675            // `inner` never contains a quote, so the literal is well-formed.
6676            let code = format!("'do;not;split return {inner}'");
6677            prop_assert!(should_prepend_return(&code), "string literal mis-split: {code:?}");
6678        }
6679    }
6680}
6681
6682#[cfg(test)]
6683mod tests {
6684    use super::*;
6685
6686    #[cfg(feature = "sqlite")]
6687    #[test]
6688    fn database_path_resolution_rejects_lexical_escape() {
6689        let dir = tempfile::tempdir().unwrap();
6690        let root = dir.path().join("allowed");
6691        std::fs::create_dir(&root).unwrap();
6692        std::fs::File::create(dir.path().join("outside.db")).unwrap();
6693
6694        let err =
6695            VictauriMcpHandler::resolve_existing_db_path(&[root], "../outside.db").unwrap_err();
6696        assert!(err.contains("path traversal"), "unexpected error: {err}");
6697    }
6698
6699    #[cfg(feature = "sqlite")]
6700    #[test]
6701    fn database_path_resolution_accepts_contained_nested_file() {
6702        let dir = tempfile::tempdir().unwrap();
6703        let root = dir.path().join("allowed");
6704        let nested = root.join("nested");
6705        std::fs::create_dir_all(&nested).unwrap();
6706        let db = nested.join("app.db");
6707        std::fs::File::create(&db).unwrap();
6708
6709        let resolved =
6710            VictauriMcpHandler::resolve_existing_db_path(&[root], "nested/app.db").unwrap();
6711        // Resolution returns the CANONICAL validated path (opened == validated, closing the
6712        // lexical-vs-canonical TOCTOU), so compare against the canonical form of the target.
6713        assert_eq!(resolved, std::fs::canonicalize(&db).unwrap());
6714    }
6715
6716    #[cfg(all(feature = "sqlite", unix))]
6717    #[test]
6718    fn database_path_resolution_rejects_symlink_escape() {
6719        use std::os::unix::fs::symlink;
6720
6721        let dir = tempfile::tempdir().unwrap();
6722        let root = dir.path().join("allowed");
6723        std::fs::create_dir(&root).unwrap();
6724        let outside = dir.path().join("outside.db");
6725        std::fs::File::create(&outside).unwrap();
6726        symlink(&outside, root.join("linked.db")).unwrap();
6727
6728        let err =
6729            VictauriMcpHandler::resolve_existing_db_path(std::slice::from_ref(&root), "linked.db")
6730                .unwrap_err();
6731        // Audit F6: an escape answers exactly like a miss (no existence oracle).
6732        let miss = VictauriMcpHandler::resolve_existing_db_path(&[root], "absent.db").unwrap_err();
6733        assert!(
6734            err.contains("database not found"),
6735            "unexpected error: {err}"
6736        );
6737        assert_eq!(
6738            err.replace("linked.db", "X"),
6739            miss.replace("absent.db", "X")
6740        );
6741    }
6742
6743    #[cfg(feature = "sqlite")]
6744    #[test]
6745    fn sqlite_identifier_quoting_handles_hostile_table_names() {
6746        let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap();
6747        let conn = rusqlite::Connection::open(file.path()).unwrap();
6748        let name = "odd\"] table";
6749        let identifier = crate::database::quote_sqlite_identifier(name);
6750        conn.execute_batch(&format!(
6751            "CREATE TABLE {identifier} (id INTEGER); INSERT INTO {identifier} VALUES (1);"
6752        ))
6753        .unwrap();
6754        let count: i64 = conn
6755            .query_row(&format!("SELECT count(*) FROM {identifier}"), [], |row| {
6756                row.get(0)
6757            })
6758            .unwrap();
6759        assert_eq!(count, 1);
6760    }
6761
6762    #[test]
6763    fn env_filter_drops_secrets_keeps_safe() {
6764        // Safe, non-secret vars pass.
6765        assert!(is_safe_env_key("HOME"));
6766        assert!(is_safe_env_key("LANG"));
6767        assert!(is_safe_env_key("TAURI_ENV_PLATFORM"));
6768        assert!(is_safe_env_key("VICTAURI_PORT"));
6769        // Secret-looking vars are dropped even under a safe prefix (audit #5).
6770        assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY"));
6771        assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"));
6772        assert!(!is_safe_env_key("VICTAURI_AUTH_TOKEN"));
6773        assert!(!is_safe_env_key("VICTAURI_API_KEY"));
6774        // Unknown prefixes are dropped regardless.
6775        assert!(!is_safe_env_key("AWS_SECRET_ACCESS_KEY"));
6776        assert!(!is_safe_env_key("RANDOM_VAR"));
6777        // The broad TAURI_ namespace is no longer allowed — only TAURI_ENV_ — so
6778        // app-custom TAURI_ secrets are dropped even without a denylist hit.
6779        assert!(!is_safe_env_key("TAURI_CUSTOM_THING"));
6780        // Adversarial leaks closed (audit #5 follow-up): connection strings,
6781        // passphrases, PATs, JWTs, etc. under an allowed prefix.
6782        assert!(!is_safe_env_key("VICTAURI_DB_DSN"));
6783        assert!(!is_safe_env_key("VICTAURI_SIGNING_PASSPHRASE"));
6784        assert!(!is_safe_env_key("VICTAURI_GH_PAT"));
6785        assert!(!is_safe_env_key("VICTAURI_JWT"));
6786        assert!(!is_safe_env_key("VICTAURI_SESSION_ID"));
6787    }
6788
6789    #[test]
6790    fn prepend_return_newline_separated_statements_are_not_wrapped() {
6791        // ASI: `return foo()\nbar()` returns foo() and never runs bar().
6792        assert!(!should_prepend_return("foo()\nbar()"));
6793        assert!(!should_prepend_return("window.x = 1\nwindow.x + 1"));
6794        assert!(!should_prepend_return("foo() // note\nbar()"));
6795        // …but an expression that visibly continues across lines is still one expression.
6796        assert!(should_prepend_return(
6797            "document\n  .querySelector('x')\n  .textContent"
6798        ));
6799        assert!(should_prepend_return("a +\n  b"));
6800        assert!(should_prepend_return("cond\n  ? a\n  : b"));
6801        assert!(should_prepend_return("[1, 2].map(x =>\n  x * 2)"));
6802        assert!(should_prepend_return("`line1\nline2`"));
6803        assert!(should_prepend_return("document.title\n"));
6804    }
6805
6806    /// The live tool list (name + description), in router order — the source of truth for the
6807    /// CLI bridge's baked `tools_fallback.json` (what an agent sees while the app is down).
6808    fn live_tool_manifest() -> serde_json::Value {
6809        let mut tools = VictauriMcpHandler::tool_router().list_all();
6810        tools.sort_by(|a, b| a.name.cmp(&b.name));
6811        serde_json::Value::Array(
6812            tools
6813                .iter()
6814                .map(|t| {
6815                    serde_json::json!({
6816                        "name": t.name.as_ref(),
6817                        "description": t.description.as_deref().unwrap_or_default(),
6818                    })
6819                })
6820                .collect(),
6821        )
6822    }
6823
6824    fn fallback_manifest_path() -> std::path::PathBuf {
6825        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
6826            .join("../victauri-cli/src/tools_fallback.json")
6827    }
6828
6829    #[test]
6830    fn cli_fallback_tool_manifest_matches_the_live_tools() {
6831        // Names AND descriptions: a stale description shown while the app is down misleads an
6832        // agent just as much as a missing tool. Regenerate with:
6833        //   VICTAURI_WRITE_FALLBACK=1 cargo test -p victauri-plugin --lib cli_fallback
6834        let path = fallback_manifest_path();
6835        let Ok(raw) = std::fs::read_to_string(&path) else {
6836            return; // packaged crate: the CLI source is not alongside
6837        };
6838        let live = live_tool_manifest();
6839        if std::env::var_os("VICTAURI_WRITE_FALLBACK").is_some() {
6840            let pretty = serde_json::to_string_pretty(&live).unwrap() + "\n";
6841            std::fs::write(&path, pretty).unwrap();
6842            return;
6843        }
6844        let mut baked: Vec<serde_json::Value> = serde_json::from_str(&raw).unwrap();
6845        baked.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
6846        assert_eq!(
6847            serde_json::Value::Array(baked),
6848            live,
6849            "crates/victauri-cli/src/tools_fallback.json is stale — regenerate it with \
6850             VICTAURI_WRITE_FALLBACK=1 cargo test -p victauri-plugin --lib cli_fallback"
6851        );
6852    }
6853
6854    #[cfg(feature = "sqlite")]
6855    #[test]
6856    fn db_path_outside_roots_gives_no_existence_oracle() {
6857        let root = tempfile::tempdir().unwrap();
6858        let roots = vec![root.path().to_path_buf()];
6859        let outside_existing = std::env::current_exe().unwrap();
6860        let outside_missing = outside_existing.with_file_name("definitely-not-here-7f3a.db");
6861        let e1 = VictauriMcpHandler::resolve_existing_db_path(
6862            &roots,
6863            outside_existing.to_str().unwrap(),
6864        )
6865        .unwrap_err();
6866        let e2 =
6867            VictauriMcpHandler::resolve_existing_db_path(&roots, outside_missing.to_str().unwrap())
6868                .unwrap_err();
6869        assert!(e1.contains("not within an allowed directory"), "{e1}");
6870        assert!(e2.contains("not within an allowed directory"), "{e2}");
6871        assert!(!e1.contains("not found") && !e2.contains("not found"));
6872        // `..` is refused outright in an absolute path (audit F6: the OS resolves it after
6873        // following any symlink, so lexical normalization cannot vouch for it).
6874        let climb = root.path().join("..").join("x.db");
6875        let e3 = VictauriMcpHandler::resolve_existing_db_path(&roots, climb.to_str().unwrap())
6876            .unwrap_err();
6877        assert!(e3.contains("'..' is rejected"), "{e3}");
6878    }
6879
6880    #[test]
6881    fn prepend_return_handles_the_red_team_asi_cases() {
6882        // Two statements that must NOT be wrapped (the second line would silently never run).
6883        for code in [
6884            "window.x++\nwindow.x",
6885            "window.x--\nwindow.x",
6886            "a\n++b",
6887            "/re/.test(s)\nfoo()",
6888            "s.replace(/\"/g, '')\nfoo()",
6889            "foo()\u{2028}bar()",
6890            "foo()\u{2029}bar()",
6891        ] {
6892            assert!(!should_prepend_return(code), "must not wrap: {code:?}");
6893        }
6894        // Valid single expressions spanning lines, which must still be wrapped.
6895        for code in [
6896            "a instanceof\nB",
6897            "typeof\nx",
6898            "await\nfoo()",
6899            "new\nFoo()",
6900            "a /\nb",
6901            "s.split(/,/)\n.length",
6902            "document.title; // trailing note",
6903            "document.title // trailing note",
6904            "obj.return",
6905            "obj.of\n.length",
6906            "i++ / 2",
6907            "`a${'`'}b`",
6908        ] {
6909            assert!(should_prepend_return(code), "must wrap: {code:?}");
6910        }
6911        // Round 2 (0.9 audit V-6): each of these used to be wrapped and silently lost a
6912        // statement, or was wrapped into a syntax error.
6913        for code in ASI_ROUND2_STATEMENT_CASES {
6914            assert!(!should_prepend_return(code), "must not wrap: {code:?}");
6915        }
6916    }
6917
6918    /// Run a Node script and return its stdout, or `None` (test skipped) without `node`.
6919    fn run_node(script: &str) -> Option<String> {
6920        let out = std::process::Command::new("node")
6921            .arg("-e")
6922            .arg(script)
6923            .output()
6924            .ok()?;
6925        Some(String::from_utf8_lossy(&out.stdout).into_owned())
6926    }
6927
6928    #[test]
6929    fn log_field_truncation_never_splits_a_surrogate_pair() {
6930        // An emoji straddling the MAX_LOG_FIELD_BYTES cut used to leave a lone high surrogate,
6931        // which serde_json rejects — failing the whole `logs` read (audit V-4).
6932        let source = format!(
6933            "[{{ body: 'a'.repeat({}) + '\\u{{1F600}}tail' }}]",
6934            MAX_LOG_FIELD_BYTES - 1
6935        );
6936        let code = trimmed_log_js(&source, 10);
6937        let script = format!(
6938            "const r = (function() {{ {code} }})(); \
6939             console.log('OUT:' + r[0].body.isWellFormed() + ':' + r[0].body.indexOf('bytes truncated'));"
6940        );
6941        let Some(out) = run_node(&script) else {
6942            eprintln!("SKIP: node not installed");
6943            return;
6944        };
6945        let line = out
6946            .lines()
6947            .find_map(|l| l.strip_prefix("OUT:"))
6948            .unwrap_or_else(|| panic!("no output: {out}"));
6949        let (well_formed, marker_at) = line.split_once(':').unwrap();
6950        assert_eq!(
6951            well_formed, "true",
6952            "truncated field is not well-formed UTF-16"
6953        );
6954        assert_ne!(marker_at, "-1", "field was not truncated");
6955    }
6956
6957    /// Multi-statement snippets from the 0.9 red-team round (audit V-6): each must run every
6958    /// statement. `f()` pushes `'f'` onto the log.
6959    const ASI_ROUND2_STATEMENT_CASES: &[&str] = &[
6960        "i++ / 2; f()",
6961        "x = {} / 1; f()",
6962        "i++\n[f()]",
6963        "i++\n(f)()",
6964        "i--\n[f()]",
6965        "i--\n(f)()",
6966        "obj.of\nf()",
6967        "obj.in\nf()",
6968        "if\t(true) f()",
6969        "if\n(true) f()",
6970        "const\ny = 5; f()",
6971        "outer: for (const a of [1]) { f() }",
6972        "outer:\nfor (const a of [1]) { f() }",
6973        "`a${'`'}b`; f()",
6974        "`${'`'}`\nf()",
6975        "f() <!-- don't\nf()",
6976        "f()\n--> it's a comment\nf()",
6977        "a = /[/]/\nf()",
6978        "typeof x / 2; f()",
6979    ];
6980
6981    /// Multi-statement snippets from the 0.9 round-4 audit (R4-EVAL2): each was wrapped with
6982    /// `return` and silently lost everything after its first statement.
6983    const ASI_ROUND4_STATEMENT_CASES: &[&str] = &[
6984        // (a) a block comment spanning a line break is a line break
6985        "x = 1 /*\n*/ f()",
6986        "x = 1 /*\r*/ f()",
6987        "x = 1 /*\u{2028}*/ f()",
6988        "x = 1 /* a\n b */ f()",
6989        // (b) a lone CR (and CRLF) is a line terminator everywhere
6990        "x = 1\rf()",
6991        "x = 1\r\nf()",
6992        "x = 1 // note\rf()",
6993        "x = 1 // note\u{2028}f()",
6994        "f() <!-- c\rf()",
6995        "f()\r--> c\rf()",
6996        "x = /a/g\rf()",
6997        // (c) a number ending in `.` is a complete operand
6998        "x = 1.\nf()",
6999        "x = 1_0.\rf()",
7000        "x = 10.\n\nf()",
7001        // (d) `yield` is an identifier in the eval wrapper; `of` is one at depth 0
7002        "x = typeof yield\nf()",
7003        "x = typeof of\nf()",
7004    ];
7005
7006    /// Multi-statement snippets from the 0.9 round-5 audit (R5-EVAL1): each was wrapped with
7007    /// `return` and lost its second statement. (1) a regex right after the `)` of an
7008    /// `if`/`while`/`for`/`with` head was read as division, so a quote or bracket inside it
7009    /// broke the scan; (2) `of` was a keyword anywhere inside brackets; (3) a non-ASCII
7010    /// identifier ending in a keyword (`énew`) was read as that keyword.
7011    const ASI_ROUND5_STATEMENT_CASES: &[&str] = &[
7012        "(function(){ if (a) /'/.test('q') })(); f()",
7013        "(function(){ if (a) /\\(/.test('q') })(); f()",
7014        "(function(){ if (a) /\"/.test('q') })(); f()",
7015        "(function(){ if (a) /`/.test('q') })(); f()",
7016        "(function(){ if (a) /[(]/.test('q') })(); f()",
7017        "(function(){ if (a) /\\[/.test('q') })(); f()",
7018        "(function(){ if (a) /\\{/.test('q') })(); f()",
7019        "(function(){ if ((a)) /'/.test('q') })(); f()",
7020        "(function(){ while (a) /'/.test('q') })(); f()",
7021        "(function(){ for (;a;) /'/.test('q') })(); f()",
7022        "(function(){ with (obj) /'/.test('q') })(); f()",
7023        "(async function(){ for await (const q of []) /'/.test('q') })(); f()",
7024        "(() => { if (a) /'/.test('q') })(); f()",
7025        "Math.abs(of / 2); f()",
7026        "Math.abs(of / 2); f() / 1",
7027        "[of / 2]; f()",
7028        "énew / 2; f() / 1",
7029        "x = énew / 2; f()",
7030        "x = \u{e9}typeof / 2; f()",
7031    ];
7032
7033    #[test]
7034    fn prepend_return_round5_shapes() {
7035        for code in ASI_ROUND5_STATEMENT_CASES {
7036            assert!(!should_prepend_return(code), "must not wrap: {code:?}");
7037        }
7038        for code in [
7039            "(x) / 2 / 1",
7040            "f(x) / 2",
7041            "f(of / 2)",
7042            "[1, 2].map(of => of / 2)",
7043            "énew / 2",
7044            "if_ (a) / 2",
7045            "obj.if (a) / 2",
7046            "(a) / 2; ",
7047        ] {
7048            assert!(should_prepend_return(code), "must wrap: {code:?}");
7049        }
7050    }
7051
7052    /// Table for R4-EVAL2: `(code, wrap?)` — every shape the fix touches, both ways.
7053    #[test]
7054    fn prepend_return_round4_shapes() {
7055        for code in ASI_ROUND4_STATEMENT_CASES {
7056            assert!(!should_prepend_return(code), "must not wrap: {code:?}");
7057        }
7058        for code in [
7059            "x /* no line break */ + 1",
7060            "x /*\n*/ + 1",
7061            "x +\r1",
7062            "x\r.toString()",
7063            "a instanceof\r\nB",
7064            "1.5\n.toFixed(1)",
7065            "obj.of.\nlength",
7066            "0x10.\ntoString()",
7067            "1e3.\ntoFixed(0)",
7068            "(1).\ntoFixed(0)",
7069            "document.title // trailing note\r",
7070            "-->x\ndocument.title",
7071            "<!-- x\rdocument.title",
7072            "// lead\u{2028}document.title",
7073            "[1].map(y => { for (const z of\n[2]) {} })",
7074        ] {
7075            assert!(should_prepend_return(code), "must wrap: {code:?}");
7076        }
7077    }
7078
7079    #[test]
7080    fn line_terminators_and_number_dots_are_recognized() {
7081        for (s, at, len) in [
7082            ("a\nb", 1, 1),
7083            ("a\rb", 1, 1),
7084            ("a\r\nb", 1, 2),
7085            ("a\u{2028}b", 1, 3),
7086            ("a\u{2029}b", 1, 3),
7087            ("a b", 1, 0),
7088            ("a\u{2027}b", 1, 0),
7089        ] {
7090            assert_eq!(line_terminator_len(s.as_bytes(), at), len, "{s:?}");
7091        }
7092        for (code, complete) in [
7093            ("1.", true),
7094            ("x = 10.", true),
7095            ("1_000.", true),
7096            ("a1.", false),
7097            ("x.", false),
7098            ("0x1.", false),
7099            ("1e3.", false),
7100            ("1n.", false),
7101            ("1.5.", false),
7102            ("(1).", false),
7103            ("1 .", false),
7104        ] {
7105            assert_eq!(
7106                dot_completes_number(code, code.len() - 1),
7107                complete,
7108                "{code:?}"
7109            );
7110        }
7111    }
7112
7113    /// Run each `(code, expected_return, expected_log)` through the REAL eval wrapper shape
7114    /// (the code inlined in an async arrow, prepended with `return` exactly when
7115    /// [`should_prepend_return`] says so) in Node, and check every statement ran and the
7116    /// right value came back. Skips when `node` is not installed.
7117    #[test]
7118    fn prepend_return_decisions_run_every_statement_in_node() {
7119        let mut cases: Vec<(&str, &str, Vec<&str>)> = ASI_ROUND2_STATEMENT_CASES
7120            .iter()
7121            .chain(ASI_ROUND4_STATEMENT_CASES)
7122            .chain(ASI_ROUND5_STATEMENT_CASES)
7123            .map(|c| {
7124                let n = c.matches("f()").count() + c.matches("(f)()").count();
7125                (*c, "undefined", vec!["f"; n])
7126            })
7127            .collect();
7128        cases.extend([
7129            ("obj.return", "\"RET\"", vec![]),
7130            ("obj.of", "\"OF\"", vec![]),
7131            ("obj.in\n.length", "2", vec![]),
7132            ("i++ / 2", "0.5", vec![]),
7133            ("`a${'`'}b`", "\"a`b\"", vec![]),
7134            ("f() <!-- trailing html comment", "1", vec!["f"]),
7135            ("f()\n+ 1", "2", vec!["f"]),
7136            ("document", "\"doc\"", vec![]),
7137            ("x +\r1", "1", vec![]),
7138            ("x /*\n*/ + 1", "1", vec![]),
7139            ("obj.of.\nlength", "2", vec![]),
7140            ("0x10.\ntoString()", "\"16\"", vec![]),
7141            ("-->x\ndocument", "\"doc\"", vec![]),
7142        ]);
7143        let bodies: Vec<String> = cases
7144            .iter()
7145            .map(|(code, _, _)| {
7146                let body = strip_leading_js_comments(code.trim());
7147                if should_prepend_return(body) {
7148                    format!("return {body}")
7149                } else {
7150                    code.trim().to_string()
7151                }
7152            })
7153            .collect();
7154        let mut file = tempfile::NamedTempFile::new().unwrap();
7155        std::io::Write::write_all(
7156            &mut file,
7157            serde_json::to_string(&bodies).unwrap().as_bytes(),
7158        )
7159        .unwrap();
7160        let runner = r"
7161            const vm = require('vm');
7162            const bodies = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'));
7163            (async () => {
7164              const out = [];
7165              for (const body of bodies) {
7166                const log = [];
7167                globalThis.__log = log;
7168                const src = '(async () => { const log = globalThis.__log; const f = () => log.push(\'f\');'
7169                  + ' let i = 1, x = 0, a, of = 4, énew = 2, étypeof = 3; const document = \'doc\';'
7170                  + ' const obj = { of: \'OF\', in: \'IN\', return: \'RET\' };\n' + body + '\n })()';
7171                let ret, err = null;
7172                try { ret = await vm.runInThisContext(src); } catch (e) { err = e.name + ': ' + e.message; }
7173                out.push({ ret: ret === undefined ? 'undefined' : JSON.stringify(ret), log, err });
7174              }
7175              console.log('ASI_RESULTS:' + JSON.stringify(out));
7176            })();
7177        ";
7178        let Ok(output) = std::process::Command::new("node")
7179            .arg("-e")
7180            .arg(runner)
7181            .arg(file.path())
7182            .output()
7183        else {
7184            eprintln!("SKIP: node not installed");
7185            return;
7186        };
7187        let stdout = String::from_utf8_lossy(&output.stdout);
7188        let line = stdout
7189            .lines()
7190            .find_map(|l| l.strip_prefix("ASI_RESULTS:"))
7191            .unwrap_or_else(|| {
7192                panic!(
7193                    "no results: {stdout}\n{}",
7194                    String::from_utf8_lossy(&output.stderr)
7195                )
7196            });
7197        let results: Vec<serde_json::Value> = serde_json::from_str(line).unwrap();
7198        let mut failures = Vec::new();
7199        for (((code, want_ret, want_log), body), got) in cases.iter().zip(&bodies).zip(&results) {
7200            let got_log: Vec<&str> = got["log"]
7201                .as_array()
7202                .unwrap()
7203                .iter()
7204                .filter_map(|v| v.as_str())
7205                .collect();
7206            if !got["err"].is_null() || got["ret"] != *want_ret || got_log != *want_log {
7207                failures.push(format!(
7208                    "{code:?} (ran as {body:?}): got ret={} log={got_log:?} err={}; want ret={want_ret} log={want_log:?}",
7209                    got["ret"], got["err"]
7210                ));
7211            }
7212        }
7213        assert!(failures.is_empty(), "\n{}", failures.join("\n"));
7214    }
7215
7216    #[test]
7217    fn prepend_return_sees_past_leading_comments() {
7218        assert!(!should_prepend_return("// setup\nconst x = 1; x"));
7219        assert!(!should_prepend_return("/* c */ if (a) b()"));
7220        assert!(should_prepend_return("// read it\ndocument.title"));
7221        assert_eq!(
7222            strip_leading_js_comments("// a\n/* b */  document.title"),
7223            "document.title"
7224        );
7225        assert_eq!(strip_leading_js_comments("// only a comment"), "");
7226    }
7227
7228    #[test]
7229    fn prepend_return_bare_expressions() {
7230        assert!(should_prepend_return("document.title"));
7231        assert!(should_prepend_return("5 + 5"));
7232        assert!(should_prepend_return("\"justexpr\""));
7233        assert!(should_prepend_return("await fetch('/x')"));
7234        assert!(should_prepend_return(
7235            "document.querySelectorAll('a').length"
7236        ));
7237        assert!(should_prepend_return("x ? a : b"));
7238        // Single trailing semicolon on a bare expression is still an expression.
7239        assert!(should_prepend_return("document.title;"));
7240        // Semicolons inside strings must not be treated as boundaries.
7241        assert!(should_prepend_return("'a;b;c'"));
7242        assert!(should_prepend_return("\"x;y\".length"));
7243        // IIFE workaround: the `;` lives inside the arrow body (depth > 0).
7244        assert!(should_prepend_return("(()=>{window.x=5; return 'ok'})()"));
7245    }
7246
7247    #[test]
7248    fn no_prepend_for_statement_blocks() {
7249        // The original silent-corruption cases.
7250        assert!(!should_prepend_return(
7251            "localStorage.setItem('k','v'); return localStorage.getItem('k')"
7252        ));
7253        assert!(!should_prepend_return(
7254            "window.scrollTo(0,50); return window.scrollY"
7255        ));
7256        assert!(!should_prepend_return("console.log('x'); return 123"));
7257        assert!(!should_prepend_return("window.__z=7; return 'ok'"));
7258        // Explicit return without a preceding semicolon (newline-separated).
7259        assert!(!should_prepend_return("window.x = 5\nreturn window.x"));
7260    }
7261
7262    #[test]
7263    fn no_prepend_for_statement_keywords() {
7264        assert!(!should_prepend_return("return 42"));
7265        assert!(!should_prepend_return("const x = 1; return x"));
7266        assert!(!should_prepend_return("let y = 2"));
7267        assert!(!should_prepend_return("var z = 3"));
7268        assert!(!should_prepend_return("if (x) { return 1 }"));
7269        assert!(!should_prepend_return("for (const x of y) doThing(x)"));
7270        assert!(!should_prepend_return("throw new Error('x')"));
7271        assert!(!should_prepend_return("function f(){}"));
7272        assert!(!should_prepend_return("{ a: 1 }")); // object-literal-as-block ambiguity → as-is
7273    }
7274
7275    #[test]
7276    fn empty_code_no_prepend() {
7277        assert!(!should_prepend_return(""));
7278        assert!(!should_prepend_return("   "));
7279    }
7280
7281    #[test]
7282    fn envelope_unwrap_value() {
7283        assert_eq!(
7284            unwrap_eval_envelope(r#"{"__victauri_ok":"4DA","__victauri_type":"value"}"#.into()),
7285            Ok("\"4DA\"".to_string())
7286        );
7287        assert_eq!(
7288            unwrap_eval_envelope(r#"{"__victauri_ok":42,"__victauri_type":"value"}"#.into()),
7289            Ok("42".to_string())
7290        );
7291    }
7292
7293    #[test]
7294    fn envelope_unwrap_undefined_null() {
7295        assert_eq!(
7296            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#.into()),
7297            Ok("undefined".to_string())
7298        );
7299        assert_eq!(
7300            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"null"}"#.into()),
7301            Ok("null".to_string())
7302        );
7303    }
7304
7305    #[test]
7306    fn envelope_unwrap_error() {
7307        let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into());
7308        assert!(r.unwrap_err().message.contains("boom"));
7309    }
7310
7311    #[test]
7312    fn envelope_unwrap_deeply_nested_does_not_leak() {
7313        // Build an envelope whose value is nested far deeper than serde_json's
7314        // default recursion limit (128). The full parse fails, so the slice
7315        // fallback must return the value — NOT the raw `__victauri_ok` envelope.
7316        let mut value = String::from("0");
7317        for _ in 0..300 {
7318            value = format!("{{\"n\":{value}}}");
7319        }
7320        let raw = format!(r#"{{"__victauri_ok":{value},"__victauri_type":"value"}}"#);
7321        let out = unwrap_eval_envelope(raw).unwrap();
7322        assert!(
7323            out.starts_with(r#"{"n":"#),
7324            "deep value should be unwrapped, got: {}",
7325            &out[..out.len().min(40)]
7326        );
7327        assert!(
7328            !out.contains("__victauri_ok"),
7329            "envelope must not leak into the result"
7330        );
7331    }
7332
7333    #[test]
7334    fn js_string_simple() {
7335        assert_eq!(js_string("hello"), "\"hello\"");
7336    }
7337
7338    #[test]
7339    fn js_string_single_quotes() {
7340        let result = js_string("it's a test");
7341        assert!(result.contains("it's a test"));
7342    }
7343
7344    #[test]
7345    fn js_string_double_quotes() {
7346        let result = js_string(r#"say "hello""#);
7347        assert!(result.contains(r#"\""#));
7348    }
7349
7350    #[test]
7351    fn js_string_backslashes() {
7352        let result = js_string(r"path\to\file");
7353        assert!(result.contains(r"\\"));
7354    }
7355
7356    #[test]
7357    fn js_string_newlines_and_tabs() {
7358        let result = js_string("line1\nline2\ttab");
7359        assert!(result.contains(r"\n"));
7360        assert!(result.contains(r"\t"));
7361        assert!(!result.contains('\n'));
7362    }
7363
7364    #[test]
7365    fn js_string_null_bytes() {
7366        let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
7367        let result = js_string(&input);
7368        // serde_json escapes null bytes as
7369        assert!(result.contains("\\u0000"));
7370        assert!(!result.contains('\0'));
7371    }
7372
7373    #[test]
7374    fn js_string_template_literal_injection() {
7375        let result = js_string("`${alert(1)}`");
7376        // Should not contain unescaped backticks that could break template literals
7377        // serde_json wraps in double quotes, so backticks are safe
7378        assert!(result.starts_with('"'));
7379        assert!(result.ends_with('"'));
7380    }
7381
7382    #[test]
7383    fn js_string_unicode_separators() {
7384        // U+2028 (Line Separator) and U+2029 (Paragraph Separator) are valid in
7385        // JSON strings per RFC 8259, and serde_json passes them through literally.
7386        // Since js_string is used inside JS double-quoted strings (not template
7387        // literals), they are safe in modern JS engines (ES2019+).
7388        let result = js_string("a\u{2028}b\u{2029}c");
7389        // Verify the string is valid JSON that round-trips correctly
7390        let decoded: String = serde_json::from_str(&result).unwrap();
7391        assert_eq!(decoded, "a\u{2028}b\u{2029}c");
7392    }
7393
7394    #[test]
7395    fn js_string_empty() {
7396        assert_eq!(js_string(""), "\"\"");
7397    }
7398
7399    #[test]
7400    fn js_string_html_script_close() {
7401        // </script> in a JS string inside HTML could break out of script tags
7402        let result = js_string("</script><img onerror=alert(1)>");
7403        assert!(result.starts_with('"'));
7404        // The string is JSON-encoded; verify it round-trips safely
7405        let decoded: String = serde_json::from_str(&result).unwrap();
7406        assert_eq!(decoded, "</script><img onerror=alert(1)>");
7407    }
7408
7409    #[test]
7410    fn js_string_very_long() {
7411        let long = "a".repeat(100_000);
7412        let result = js_string(&long);
7413        assert!(result.len() >= 100_002); // quotes + content
7414    }
7415
7416    // ── URL validation tests ────────────────────────────────────────────────
7417
7418    #[test]
7419    fn url_allows_http() {
7420        assert!(validate_url("http://example.com", false).is_ok());
7421    }
7422
7423    #[test]
7424    fn url_allows_https() {
7425        assert!(validate_url("https://example.com/path?q=1", false).is_ok());
7426    }
7427
7428    #[test]
7429    fn url_allows_http_localhost() {
7430        assert!(validate_url("http://localhost:3000", false).is_ok());
7431    }
7432
7433    #[test]
7434    fn url_blocks_file_by_default() {
7435        let err = validate_url("file:///etc/passwd", false).unwrap_err();
7436        assert!(err.contains("file"), "error should mention the file scheme");
7437    }
7438
7439    #[test]
7440    fn url_allows_file_when_opted_in() {
7441        assert!(validate_url("file:///tmp/test.html", true).is_ok());
7442    }
7443
7444    #[test]
7445    fn url_blocks_javascript() {
7446        assert!(validate_url("javascript:alert(1)", false).is_err());
7447    }
7448
7449    #[test]
7450    fn url_blocks_javascript_case_insensitive() {
7451        assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
7452    }
7453
7454    #[test]
7455    fn url_blocks_data_scheme() {
7456        assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
7457    }
7458
7459    #[test]
7460    fn url_blocks_vbscript() {
7461        assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
7462    }
7463
7464    #[test]
7465    fn url_rejects_invalid() {
7466        assert!(validate_url("not a url at all", false).is_err());
7467    }
7468
7469    #[test]
7470    fn url_strips_control_chars() {
7471        // Control characters should be stripped, leaving a valid URL
7472        let input = format!("http://example{}com", '\0');
7473        assert!(validate_url(&input, false).is_ok());
7474    }
7475
7476    // ── CSS color sanitization tests ───────────────────────────────────────
7477
7478    #[test]
7479    fn css_color_valid_hex() {
7480        assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
7481        assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
7482        assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
7483    }
7484
7485    #[test]
7486    fn css_color_valid_rgb() {
7487        assert_eq!(
7488            sanitize_css_color("rgb(255, 0, 0)").unwrap(),
7489            "rgb(255, 0, 0)"
7490        );
7491        assert_eq!(
7492            sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
7493            "rgba(0, 0, 0, 0.5)"
7494        );
7495    }
7496
7497    #[test]
7498    fn css_color_valid_named() {
7499        assert_eq!(sanitize_css_color("red").unwrap(), "red");
7500        assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
7501    }
7502
7503    #[test]
7504    fn css_color_valid_hsl() {
7505        assert_eq!(
7506            sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
7507            "hsl(120, 50%, 50%)"
7508        );
7509    }
7510
7511    #[test]
7512    fn css_color_rejects_too_long() {
7513        let long = "a".repeat(101);
7514        assert!(sanitize_css_color(&long).is_err());
7515    }
7516
7517    #[test]
7518    fn css_color_rejects_backslash_escapes() {
7519        assert!(sanitize_css_color(r"red\00").is_err());
7520        assert!(sanitize_css_color(r"\72\65\64").is_err());
7521    }
7522
7523    #[test]
7524    fn css_color_rejects_url_injection() {
7525        assert!(sanitize_css_color("url(http://evil.com)").is_err());
7526        assert!(sanitize_css_color("URL(http://evil.com)").is_err());
7527    }
7528
7529    #[test]
7530    fn css_color_rejects_expression_injection() {
7531        assert!(sanitize_css_color("expression(alert(1))").is_err());
7532        assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
7533    }
7534
7535    #[test]
7536    fn css_color_rejects_import() {
7537        assert!(sanitize_css_color("@import url(evil.css)").is_err());
7538    }
7539
7540    #[test]
7541    fn css_color_rejects_semicolons_and_braces() {
7542        assert!(sanitize_css_color("red; background: url(evil)").is_err());
7543        assert!(sanitize_css_color("red} body { color: blue").is_err());
7544    }
7545
7546    #[test]
7547    fn css_color_rejects_special_chars() {
7548        assert!(sanitize_css_color("red<script>").is_err());
7549        assert!(sanitize_css_color("red\"onload=alert").is_err());
7550        assert!(sanitize_css_color("red'onclick=alert").is_err());
7551    }
7552
7553    #[test]
7554    fn css_color_trims_whitespace() {
7555        assert_eq!(sanitize_css_color("  red  ").unwrap(), "red");
7556    }
7557
7558    #[test]
7559    fn css_color_empty_string() {
7560        assert_eq!(sanitize_css_color("").unwrap(), "");
7561    }
7562}
7563
7564/// Dispatch-level authorization tests.
7565///
7566/// These exercise the REAL `execute_tool` dispatch path (not just the privacy
7567/// string matrix) to prove that blocked tools/actions actually return
7568/// `tool_disabled` and never reach their handler. This is the negative security
7569/// suite the audit required (Gate #5): the prior tests validated
7570/// `is_tool_enabled(...)` in isolation, which let structural dispatch bypasses
7571/// pass undetected.
7572#[cfg(test)]
7573mod authz_dispatch_tests {
7574    use super::*;
7575    use crate::bridge::WebviewBridge;
7576    use crate::privacy::PrivacyConfig;
7577    use std::collections::{HashMap, HashSet};
7578    use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
7579
7580    /// A bridge whose eval always fails immediately, so an *allowed* action that
7581    /// reaches the bridge returns a non-privacy error fast (no 30s hang), while a
7582    /// *blocked* action is rejected by dispatch before the bridge is ever touched.
7583    struct RejectingBridge;
7584
7585    impl WebviewBridge for RejectingBridge {
7586        fn eval_webview(&self, _label: Option<&str>, _script: &str) -> Result<(), String> {
7587            Err("eval rejected in authz dispatch test".to_string())
7588        }
7589        fn get_window_states(&self, _label: Option<&str>) -> Vec<WindowState> {
7590            Vec::new()
7591        }
7592        fn list_window_labels(&self) -> Vec<String> {
7593            Vec::new()
7594        }
7595        fn get_native_handle(&self, _label: Option<&str>) -> Result<isize, String> {
7596            Err("no handle".to_string())
7597        }
7598        fn manage_window(&self, _label: Option<&str>, _action: &str) -> Result<String, String> {
7599            Err("no window".to_string())
7600        }
7601        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
7602            Ok(())
7603        }
7604        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
7605            Ok(())
7606        }
7607        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
7608            Ok(())
7609        }
7610    }
7611
7612    fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
7613        Arc::new(VictauriState {
7614            event_log: EventLog::new(1000),
7615            registry: CommandRegistry::new(),
7616            port: std::sync::atomic::AtomicU16::new(0),
7617            pending_evals: Arc::new(Mutex::new(HashMap::new())),
7618            recorder: EventRecorder::new(1000),
7619            privacy,
7620            eval_timeout: std::time::Duration::from_millis(100),
7621            shutdown_tx: tokio::sync::watch::channel(false).0,
7622            started_at: std::time::Instant::now(),
7623            tool_invocations: std::sync::atomic::AtomicU64::new(0),
7624            allow_file_navigation: false,
7625            command_timings: crate::introspection::CommandTimings::new(),
7626            fault_registry: crate::introspection::FaultRegistry::new(),
7627            contract_store: crate::introspection::ContractStore::new(),
7628            startup_timeline: crate::introspection::StartupTimeline::new(),
7629            event_bus: crate::introspection::EventBusMonitor::default(),
7630            task_tracker: crate::introspection::TaskTracker::new(),
7631            bridge_ready: std::sync::atomic::AtomicBool::new(true),
7632            bridge_notify: tokio::sync::Notify::new(),
7633            db_search_paths: Vec::new(),
7634            screencast: Arc::new(crate::screencast::Screencast::default()),
7635            probes: crate::introspection::AppStateProbes::default(),
7636            drain_watermarks: crate::introspection::DrainWatermarks::default(),
7637            page_loads: crate::introspection::PageLoads::default(),
7638        })
7639    }
7640
7641    fn handler(privacy: PrivacyConfig) -> VictauriMcpHandler {
7642        VictauriMcpHandler::new(state_with(privacy), Arc::new(RejectingBridge))
7643    }
7644
7645    /// True iff the result is a privacy/authorization block (vs any other error).
7646    fn is_privacy_blocked(r: &CallToolResult) -> bool {
7647        r.is_error == Some(true)
7648            && r.content.iter().any(|c| {
7649                matches!(c, ContentBlock::Text(t)
7650                    if t.text.contains("disabled by privacy configuration"))
7651            })
7652    }
7653
7654    async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
7655        match h.execute_tool(tool, args).await {
7656            Ok(r) => r,
7657            Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
7658        }
7659    }
7660
7661    /// The action strings a params type's `action` enum accepts, from its JSON schema.
7662    fn schema_actions<T: schemars::JsonSchema>() -> Vec<String> {
7663        fn collect(v: &serde_json::Value, out: &mut Vec<String>) {
7664            if let Some(values) = v.get("enum").and_then(serde_json::Value::as_array) {
7665                out.extend(values.iter().filter_map(|x| x.as_str().map(String::from)));
7666            }
7667            if let Some(c) = v.get("const").and_then(serde_json::Value::as_str) {
7668                out.push(c.to_string());
7669            }
7670            for key in ["oneOf", "anyOf"] {
7671                for sub in v
7672                    .get(key)
7673                    .and_then(serde_json::Value::as_array)
7674                    .into_iter()
7675                    .flatten()
7676                {
7677                    collect(sub, out);
7678                }
7679            }
7680        }
7681        let schema = serde_json::to_value(schemars::schema_for!(T)).unwrap();
7682        let mut action = schema["properties"]["action"].clone();
7683        if let Some(target) = action.get("$ref").and_then(serde_json::Value::as_str) {
7684            let name = target.rsplit('/').next().unwrap();
7685            action = schema["$defs"][name].clone();
7686        }
7687        let mut out = Vec::new();
7688        collect(&action, &mut out);
7689        out
7690    }
7691
7692    /// `resolve_capability` gates an unknown STRING action as the bare tool name,
7693    /// trusting the typed parse to reject it. That holds only if every action the parse
7694    /// ACCEPTS is mapped to its own capability — pinned here from the enums themselves,
7695    /// so a new variant cannot silently fall back to the bare-name gate.
7696    #[test]
7697    fn every_action_variant_has_a_capability() {
7698        let tools: &[(&str, Vec<String>)] = &[
7699            ("interact", schema_actions::<InteractParams>()),
7700            ("input", schema_actions::<InputParams>()),
7701            ("window", schema_actions::<WindowParams>()),
7702            ("storage", schema_actions::<StorageParams>()),
7703            ("navigate", schema_actions::<NavigateParams>()),
7704            ("recording", schema_actions::<RecordingParams>()),
7705            ("inspect", schema_actions::<InspectParams>()),
7706            ("css", schema_actions::<CssParams>()),
7707            ("route", schema_actions::<RouteParams>()),
7708            ("trace", schema_actions::<TraceParams>()),
7709            ("animation", schema_actions::<AnimationParams>()),
7710            ("logs", schema_actions::<LogsParams>()),
7711            ("introspect", schema_actions::<IntrospectParams>()),
7712            ("fault", schema_actions::<FaultParams>()),
7713            ("explain", schema_actions::<ExplainParams>()),
7714        ];
7715        for (tool, actions) in tools {
7716            assert!(
7717                !actions.is_empty(),
7718                "{tool}: no actions read from its schema"
7719            );
7720            for action in actions {
7721                assert!(
7722                    authz::action_capability(tool, action).is_some(),
7723                    "{tool}.{action} is accepted by the parser but has no capability"
7724                );
7725            }
7726        }
7727    }
7728
7729    /// The names `disable_tools` validates against are the live tool surface: every
7730    /// registered tool is either a compound tool or in `STANDALONE_TOOLS`, and nothing else.
7731    #[test]
7732    fn disable_tools_name_list_matches_the_live_tools() {
7733        let mut live: Vec<String> = VictauriMcpHandler::tool_router()
7734            .list_all()
7735            .into_iter()
7736            .map(|t| t.name.to_string())
7737            .filter(|n| !authz::is_compound_tool(n))
7738            .collect();
7739        let mut listed: Vec<String> = authz::STANDALONE_TOOLS
7740            .iter()
7741            .map(|s| (*s).to_string())
7742            .collect();
7743        live.sort_unstable();
7744        listed.sort_unstable();
7745        assert_eq!(live, listed);
7746        for t in VictauriMcpHandler::tool_router().list_all() {
7747            assert!(authz::is_known_name(&t.name), "{}", t.name);
7748        }
7749    }
7750
7751    /// Audit N1: `{"action": {"go_to": null}}` (a tag-shaped enum serde accepts) and a
7752    /// positional array body used to be gated as the bare tool name, which the Test
7753    /// profile allows for `navigate` — the handler then parsed and ran `go_to`. Both
7754    /// shapes must now be refused as invalid params before any handler runs, in every
7755    /// profile (`FullControl` with the action disabled is the other half of the bypass).
7756    #[tokio::test]
7757    async fn non_string_action_cannot_slip_past_the_gate() {
7758        let mut full_minus_go_to = PrivacyConfig::default();
7759        full_minus_go_to
7760            .disabled_tools
7761            .insert("navigate.go_to".to_string());
7762        for privacy in [crate::privacy::test_privacy_config(), full_minus_go_to] {
7763            let h = handler(privacy);
7764            for args in [
7765                serde_json::json!({"action": {"go_to": null}, "url": "https://evil.example"}),
7766                serde_json::json!(["go_to", "https://evil.example", null, null, null, null]),
7767                serde_json::json!({"action": 0, "url": "https://evil.example"}),
7768            ] {
7769                match h.execute_tool("navigate", args.clone()).await {
7770                    Err(rest::ToolCallError::InvalidParams(_)) => {}
7771                    Ok(r) => panic!("{args} reached dispatch: {:?}", r.content),
7772                    Err(rest::ToolCallError::UnknownTool(t)) => panic!("{args}: unknown tool {t}"),
7773                }
7774            }
7775        }
7776    }
7777
7778    // ── Observe profile: every mutation/eval/compound-action must be blocked ──
7779
7780    #[tokio::test]
7781    async fn observe_blocks_mutations_and_eval_through_dispatch() {
7782        let h = handler(crate::privacy::observe_privacy_config());
7783        let blocked: &[(&str, serde_json::Value)] = &[
7784            ("eval_js", serde_json::json!({"code": "1"})),
7785            (
7786                "wait_for",
7787                serde_json::json!({"condition": "expression", "value": "true"}),
7788            ),
7789            ("screenshot", serde_json::json!({})),
7790            ("invoke_command", serde_json::json!({"command": "greet"})),
7791            ("verify_state", serde_json::json!({"frontend_expr": "1"})),
7792            (
7793                "assert_semantic",
7794                serde_json::json!({"expression": "1", "condition": "truthy"}),
7795            ),
7796            (
7797                "interact",
7798                serde_json::json!({"action": "click", "ref_id": "e1"}),
7799            ),
7800            (
7801                "input",
7802                serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
7803            ),
7804            (
7805                "storage",
7806                serde_json::json!({"action": "set", "key": "k", "value": "v"}),
7807            ),
7808            (
7809                "storage",
7810                serde_json::json!({"action": "delete", "key": "k"}),
7811            ),
7812            (
7813                "window",
7814                serde_json::json!({"action": "manage", "manage_action": "close"}),
7815            ),
7816            (
7817                "window",
7818                serde_json::json!({"action": "set_title", "title": "x"}),
7819            ),
7820            (
7821                "navigate",
7822                serde_json::json!({"action": "go_to", "url": "https://e.com"}),
7823            ),
7824            (
7825                "css",
7826                serde_json::json!({"action": "inject", "css": "body{}"}),
7827            ),
7828            ("route", serde_json::json!({"action": "clear_all"})),
7829            ("recording", serde_json::json!({"action": "start"})),
7830            ("recording", serde_json::json!({"action": "replay"})),
7831            ("logs", serde_json::json!({"action": "clear"})),
7832            (
7833                "fault",
7834                serde_json::json!({"action": "inject", "command": "x", "fault_type": "error"}),
7835            ),
7836            (
7837                "introspect",
7838                serde_json::json!({"action": "command_timings"}),
7839            ),
7840        ];
7841        for (tool, args) in blocked {
7842            let r = call(&h, tool, args.clone()).await;
7843            assert!(
7844                is_privacy_blocked(&r),
7845                "Observe must block {tool} {args} at dispatch, got: {:?}",
7846                r.content
7847            );
7848        }
7849    }
7850
7851    #[tokio::test]
7852    async fn observe_allows_read_only_through_dispatch() {
7853        let h = handler(crate::privacy::observe_privacy_config());
7854        // These reads must NOT be privacy-blocked (they may fail for other reasons
7855        // against the rejecting bridge, but never with a privacy block).
7856        let allowed: &[(&str, serde_json::Value)] = &[
7857            ("get_registry", serde_json::json!({})),
7858            ("get_memory_stats", serde_json::json!({})),
7859            ("window", serde_json::json!({"action": "list"})),
7860            ("logs", serde_json::json!({"action": "ipc"})),
7861            (
7862                "inspect",
7863                serde_json::json!({"action": "get_styles", "ref_id": "e1"}),
7864            ),
7865        ];
7866        for (tool, args) in allowed {
7867            let r = call(&h, tool, args.clone()).await;
7868            assert!(
7869                !is_privacy_blocked(&r),
7870                "Observe must allow {tool} {args} at dispatch (blocked unexpectedly)"
7871            );
7872        }
7873    }
7874
7875    // ── Test profile: interactions allowed, eval/replay/route blocked ─────────
7876
7877    #[tokio::test]
7878    async fn test_profile_dispatch_boundaries() {
7879        let h = handler(crate::privacy::test_privacy_config());
7880        // Allowed in Test:
7881        for (tool, args) in [
7882            (
7883                "interact",
7884                serde_json::json!({"action": "click", "ref_id": "e1"}),
7885            ),
7886            (
7887                "input",
7888                serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
7889            ),
7890            (
7891                "storage",
7892                serde_json::json!({"action": "set", "key": "k", "value": "v"}),
7893            ),
7894            ("navigate", serde_json::json!({"action": "go_back"})),
7895            ("recording", serde_json::json!({"action": "start"})),
7896            ("logs", serde_json::json!({"action": "clear"})),
7897        ] {
7898            let r = call(&h, tool, args.clone()).await;
7899            assert!(!is_privacy_blocked(&r), "Test must allow {tool} {args}");
7900        }
7901        // Blocked in Test (arbitrary eval, navigation mutation, replay, FullControl tools):
7902        for (tool, args) in [
7903            ("eval_js", serde_json::json!({"code": "1"})),
7904            (
7905                "wait_for",
7906                serde_json::json!({"condition": "expression", "value": "true"}),
7907            ),
7908            ("verify_state", serde_json::json!({"frontend_expr": "1"})),
7909            (
7910                "navigate",
7911                serde_json::json!({"action": "go_to", "url": "https://e.com"}),
7912            ),
7913            ("recording", serde_json::json!({"action": "replay"})),
7914            (
7915                "route",
7916                serde_json::json!({"action": "add", "pattern": "x"}),
7917            ),
7918            ("css", serde_json::json!({"action": "inject", "css": "x"})),
7919            (
7920                "window",
7921                serde_json::json!({"action": "set_title", "title": "x"}),
7922            ),
7923        ] {
7924            let r = call(&h, tool, args.clone()).await;
7925            assert!(is_privacy_blocked(&r), "Test must block {tool} {args}");
7926        }
7927    }
7928
7929    // ── disabled_tools: bare-name disable covers all of a compound tool's
7930    //    actions, and per-action disable is honored even when the handler
7931    //    historically did not check it (the route.clear bypass). ──────────────
7932
7933    #[tokio::test]
7934    async fn disabling_bare_compound_tool_blocks_all_actions() {
7935        let cfg = PrivacyConfig {
7936            disabled_tools: HashSet::from(["recording".to_string()]),
7937            ..Default::default()
7938        }; // FullControl with the whole `recording` tool disabled
7939        let h = handler(cfg);
7940        for action in ["start", "stop", "replay", "import", "export"] {
7941            let r = call(&h, "recording", serde_json::json!({"action": action})).await;
7942            assert!(
7943                is_privacy_blocked(&r),
7944                "disabling bare `recording` must block recording.{action}"
7945            );
7946        }
7947    }
7948
7949    #[tokio::test]
7950    async fn disabling_specific_action_is_honored_at_dispatch() {
7951        // The historical bypass: `route.clear`'s handler had no per-action check,
7952        // so a `disabled_tools` entry for it was silently ignored. The central
7953        // gate now enforces it.
7954        let cfg = PrivacyConfig {
7955            disabled_tools: HashSet::from([
7956                "route.clear".to_string(),
7957                "route.clear_all".to_string(),
7958            ]),
7959            ..Default::default()
7960        }; // FullControl: everything else allowed
7961        let h = handler(cfg);
7962
7963        let blocked = call(&h, "route", serde_json::json!({"action": "clear", "id": 1})).await;
7964        assert!(is_privacy_blocked(&blocked), "route.clear must be blocked");
7965        let blocked_all = call(&h, "route", serde_json::json!({"action": "clear_all"})).await;
7966        assert!(
7967            is_privacy_blocked(&blocked_all),
7968            "route.clear_all must be blocked"
7969        );
7970
7971        // A sibling action the operator did NOT disable is still reachable.
7972        let allowed = call(&h, "route", serde_json::json!({"action": "list"})).await;
7973        assert!(
7974            !is_privacy_blocked(&allowed),
7975            "route.list must remain allowed"
7976        );
7977    }
7978
7979    /// R4-NET2: `animation scrub` with `capture=true` takes native window screenshots, so an
7980    /// operator who disabled `screenshot` must not get pixels through it (`trace` already
7981    /// required both). Without capture, scrub is a plain page read and stays allowed.
7982    #[tokio::test]
7983    async fn animation_scrub_capture_requires_the_screenshot_tool() {
7984        let cfg = PrivacyConfig {
7985            disabled_tools: HashSet::from(["screenshot".to_string()]),
7986            ..Default::default()
7987        };
7988        let h = handler(cfg);
7989        let r = call(
7990            &h,
7991            "animation",
7992            serde_json::json!({"action": "scrub", "selector": "#toast", "capture": true}),
7993        )
7994        .await;
7995        assert!(
7996            is_privacy_blocked(&r),
7997            "scrub capture must be refused while `screenshot` is disabled, got: {:?}",
7998            r.content
7999        );
8000        assert!(
8001            r.content
8002                .iter()
8003                .any(|c| matches!(c, ContentBlock::Text(t) if t.text.contains("screenshot"))),
8004            "the refusal must name the screenshot tool: {:?}",
8005            r.content
8006        );
8007        let r = call(
8008            &h,
8009            "animation",
8010            serde_json::json!({"action": "scrub", "selector": "#toast"}),
8011        )
8012        .await;
8013        assert!(!is_privacy_blocked(&r), "scrub without capture is allowed");
8014    }
8015
8016    /// R4-NET4: the action spelling of an `inspect` capability disables it at dispatch.
8017    #[tokio::test]
8018    async fn disabling_an_inspect_action_by_its_action_name_is_honored() {
8019        let cfg = PrivacyConfig {
8020            disabled_tools: HashSet::from(["inspect.get_styles".to_string()]),
8021            ..Default::default()
8022        };
8023        let h = handler(cfg);
8024        let r = call(
8025            &h,
8026            "inspect",
8027            serde_json::json!({"action": "get_styles", "ref_id": "e1"}),
8028        )
8029        .await;
8030        assert!(
8031            is_privacy_blocked(&r),
8032            "inspect.get_styles must be blocked, got: {:?}",
8033            r.content
8034        );
8035        let r = call(
8036            &h,
8037            "inspect",
8038            serde_json::json!({"action": "get_bounding_boxes", "ref_ids": ["e1"]}),
8039        )
8040        .await;
8041        assert!(!is_privacy_blocked(&r), "a sibling action stays allowed");
8042    }
8043
8044    // Command-policy enforcement on invoke paths (A1/A2) and resource gating (B1)
8045    // are covered with side-effect detection (a bridge that records actual invokes)
8046    // in the `command_policy_dispatch_tests` module below — that proves the blocked
8047    // command never reaches the bridge, not merely that an error string is returned.
8048
8049    #[tokio::test]
8050    async fn full_control_allows_everything_at_dispatch() {
8051        let h = handler(PrivacyConfig::default());
8052        for (tool, args) in [
8053            ("recording", serde_json::json!({"action": "replay"})),
8054            ("route", serde_json::json!({"action": "clear_all"})),
8055            ("eval_js", serde_json::json!({"code": "1"})),
8056            ("fault", serde_json::json!({"action": "list"})),
8057        ] {
8058            let r = call(&h, tool, args.clone()).await;
8059            assert!(
8060                !is_privacy_blocked(&r),
8061                "FullControl must allow {tool} {args}"
8062            );
8063        }
8064    }
8065}
8066
8067/// Command-policy enforcement on EVERY command-invoking path (audit #30/#31, triage A1/A2).
8068///
8069/// The prior privacy suite validated the permission-string matrix — `is_tool_enabled("x")`
8070/// in isolation — which let structural dispatch bypasses pass undetected (the audit's
8071/// central criticism: "tests validate the STRING MATRIX, not actual dispatch behavior").
8072///
8073/// These tests instead drive the REAL dispatcher with a bridge that records every script
8074/// handed to `eval_webview`, and assert the dangerous **side effect** — the
8075/// `__TAURI_INTERNALS__.invoke(<command>)` script — is NEVER emitted when the command is on
8076/// the operator's blocklist, on each path that invokes commands OUTSIDE `invoke_command`:
8077/// `recording.replay`, `recording.import` + `replay`, `introspect.contract_record`, and
8078/// `introspect.contract_check`. Each has a positive control proving an *allowed* command IS
8079/// invoked (so a blanket-block can't make the negative test pass vacuously).
8080#[cfg(test)]
8081mod command_policy_dispatch_tests {
8082    use super::*;
8083    use crate::bridge::WebviewBridge;
8084    use crate::privacy::PrivacyConfig;
8085    use serde_json::json;
8086    use std::collections::{HashMap, HashSet};
8087    use std::sync::Mutex as StdMutex;
8088    use victauri_core::{
8089        AppEvent, CommandRegistry, EventLog, EventRecorder, IpcCall, IpcResult, RecordedEvent,
8090        RecordedSession, WindowState,
8091    };
8092
8093    /// A bridge that RECORDS every script passed to `eval_webview` (so a test can assert a
8094    /// blocklisted command's invoke was never emitted) then fails the eval fast — an allowed
8095    /// command is observably *attempted* without hanging on a callback that never arrives.
8096    ///
8097    /// When constructed via [`RecordingBridge::answering`] it also resolves the pre-eval
8098    /// liveness probe, simulating a healthy webview so an ALLOWED command's invoke actually
8099    /// reaches the bridge. Default-constructed bridges leave the probe unanswered — which is
8100    /// fine for negative tests, since a blocked command is rejected at the privacy gate
8101    /// *before* any eval (and thus never probes).
8102    #[derive(Clone, Default)]
8103    struct RecordingBridge {
8104        scripts: Arc<StdMutex<Vec<String>>>,
8105        pending_evals: Option<crate::PendingCallbacks>,
8106        /// The nonce of the page currently loaded (reported by the liveness probe).
8107        page_nonce: Arc<StdMutex<Option<String>>>,
8108        /// When set, the eval wrapper script is answered with this callback body.
8109        eval_answer: Arc<StdMutex<Option<String>>>,
8110        /// Every trusted (OS-level) input delivered, e.g. `click 50,26` / `type hi` / `key Enter`.
8111        natives: Arc<StdMutex<Vec<String>>>,
8112        /// While set, the liveness probe is never answered (a busy or wedged UI thread).
8113        probe_silent: Arc<AtomicBool>,
8114    }
8115
8116    /// Extract the 36-char eval id from a probe script of the form `…id:"<uuid>"…`.
8117    fn extract_probe_id(script: &str) -> Option<String> {
8118        let start = script.find("id:\"")? + 4;
8119        script.get(start..start + 36).map(str::to_string)
8120    }
8121
8122    /// Extract the eval id from the eval wrapper script (`const __vic = { id: "<uuid>", …`).
8123    fn extract_wrapper_id(script: &str) -> Option<String> {
8124        let start = script.find("__vic = { id: \"")? + 15;
8125        script.get(start..start + 36).map(str::to_string)
8126    }
8127
8128    impl RecordingBridge {
8129        /// A recording bridge that answers the liveness probe with the state's pending-evals
8130        /// map, so a permitted command's eval proceeds past the probe and is observably
8131        /// injected.
8132        fn answering(pending_evals: crate::PendingCallbacks) -> Self {
8133            Self {
8134                pending_evals: Some(pending_evals),
8135                ..Self::default()
8136            }
8137        }
8138
8139        /// Like [`answering`](Self::answering), in a page whose nonce is `nonce`.
8140        fn in_page(pending_evals: crate::PendingCallbacks, nonce: &str) -> Self {
8141            let b = Self::answering(pending_evals);
8142            b.load_page(nonce);
8143            b
8144        }
8145
8146        /// The window now shows a page with this nonce (what the liveness probe reports).
8147        fn load_page(&self, nonce: &str) {
8148            *self
8149                .page_nonce
8150                .lock()
8151                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(nonce.to_string());
8152        }
8153
8154        fn answer_evals_with(&self, body: &str) {
8155            *self
8156                .eval_answer
8157                .lock()
8158                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(body.to_string());
8159        }
8160
8161        fn natives(&self) -> Vec<String> {
8162            self.natives
8163                .lock()
8164                .unwrap_or_else(std::sync::PoisonError::into_inner)
8165                .clone()
8166        }
8167
8168        fn record_native(&self, what: String) -> Result<(), String> {
8169            self.natives
8170                .lock()
8171                .unwrap_or_else(std::sync::PoisonError::into_inner)
8172                .push(what);
8173            Ok(())
8174        }
8175
8176        /// True iff any recorded eval script invoked `command` via the Tauri IPC bridge.
8177        fn invoked(&self, command: &str) -> bool {
8178            let needle = format!("invoke({}", js_string(command));
8179            self.scripts
8180                .lock()
8181                .unwrap_or_else(std::sync::PoisonError::into_inner)
8182                .iter()
8183                .any(|s| s.contains(&needle))
8184        }
8185    }
8186
8187    impl WebviewBridge for RecordingBridge {
8188        fn eval_webview(&self, _label: Option<&str>, script: &str) -> Result<(), String> {
8189            self.scripts
8190                .lock()
8191                .unwrap_or_else(std::sync::PoisonError::into_inner)
8192                .push(script.to_string());
8193            // If wired with a pending-evals map, answer the pre-eval liveness probe
8194            // (simulating a healthy webview) so the real eval proceeds past it. The
8195            // real eval is still left unanswered, so it times out fast at the 100ms
8196            // test `eval_timeout` — we only care WHICH scripts reached the bridge,
8197            // never the eval's return value.
8198            let answer = if script.contains("probe_ok") {
8199                if self.probe_silent.load(Ordering::SeqCst) {
8200                    return Ok(());
8201                }
8202                extract_probe_id(script).map(|id| {
8203                    let nonce = self
8204                        .page_nonce
8205                        .lock()
8206                        .unwrap_or_else(std::sync::PoisonError::into_inner)
8207                        .clone();
8208                    let body = nonce.map_or_else(
8209                        || "\"probe_ok\"".to_string(),
8210                        |n| format!("\"probe_ok:{n}\""),
8211                    );
8212                    (id, body)
8213                })
8214            } else {
8215                let body = self
8216                    .eval_answer
8217                    .lock()
8218                    .unwrap_or_else(std::sync::PoisonError::into_inner)
8219                    .clone();
8220                extract_wrapper_id(script).zip(body)
8221            };
8222            if let (Some(pending), Some((id, body))) = (&self.pending_evals, answer) {
8223                let pending = pending.clone();
8224                std::thread::spawn(move || {
8225                    let mut map = pending.blocking_lock();
8226                    if let Some(tx) = map.remove(&id) {
8227                        let _ = tx.send(body);
8228                    }
8229                });
8230            }
8231            // Return Ok so `eval_with_return` injects BOTH its watchdog and the
8232            // user-code script (it bails on the first Err).
8233            Ok(())
8234        }
8235        fn native_click(&self, _l: Option<&str>, x: f64, y: f64) -> Result<(), String> {
8236            self.record_native(format!("click {x},{y}"))
8237        }
8238        fn native_type_text(&self, _l: Option<&str>, text: &str) -> Result<(), String> {
8239            self.record_native(format!("type {text}"))
8240        }
8241        fn native_key(&self, _l: Option<&str>, key: &str) -> Result<(), String> {
8242            self.record_native(format!("key {key}"))
8243        }
8244        fn get_window_states(&self, _l: Option<&str>) -> Vec<WindowState> {
8245            Vec::new()
8246        }
8247        fn list_window_labels(&self) -> Vec<String> {
8248            Vec::new()
8249        }
8250        fn get_native_handle(&self, _l: Option<&str>) -> Result<isize, String> {
8251            Err("no handle".to_string())
8252        }
8253        fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
8254            Err("no window".to_string())
8255        }
8256        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
8257            Ok(())
8258        }
8259        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
8260            Ok(())
8261        }
8262        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
8263            Ok(())
8264        }
8265    }
8266
8267    fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
8268        Arc::new(VictauriState {
8269            event_log: EventLog::new(1000),
8270            registry: CommandRegistry::new(),
8271            port: std::sync::atomic::AtomicU16::new(0),
8272            pending_evals: Arc::new(Mutex::new(HashMap::new())),
8273            recorder: EventRecorder::new(1000),
8274            privacy,
8275            eval_timeout: std::time::Duration::from_millis(100),
8276            shutdown_tx: tokio::sync::watch::channel(false).0,
8277            started_at: std::time::Instant::now(),
8278            tool_invocations: std::sync::atomic::AtomicU64::new(0),
8279            allow_file_navigation: false,
8280            command_timings: crate::introspection::CommandTimings::new(),
8281            fault_registry: crate::introspection::FaultRegistry::new(),
8282            contract_store: crate::introspection::ContractStore::new(),
8283            startup_timeline: crate::introspection::StartupTimeline::new(),
8284            event_bus: crate::introspection::EventBusMonitor::default(),
8285            task_tracker: crate::introspection::TaskTracker::new(),
8286            bridge_ready: std::sync::atomic::AtomicBool::new(true),
8287            bridge_notify: tokio::sync::Notify::new(),
8288            db_search_paths: Vec::new(),
8289            screencast: Arc::new(crate::screencast::Screencast::default()),
8290            probes: crate::introspection::AppStateProbes::default(),
8291            drain_watermarks: crate::introspection::DrainWatermarks::default(),
8292            page_loads: crate::introspection::PageLoads::default(),
8293        })
8294    }
8295
8296    // FullControl, except the named commands are blocklisted — exactly the scenario
8297    // the audit flagged: an operator who trusts `command_blocklist` to stop a
8298    // dangerous command.
8299    fn blocking(cmds: &[&str]) -> PrivacyConfig {
8300        PrivacyConfig {
8301            command_blocklist: cmds.iter().map(|s| (*s).to_string()).collect(),
8302            ..Default::default()
8303        }
8304    }
8305
8306    fn ipc_event(command: &str) -> AppEvent {
8307        AppEvent::Ipc(IpcCall::new(
8308            format!("c-{command}"),
8309            command.to_string(),
8310            chrono::Utc::now(),
8311            IpcResult::Ok(json!(true)),
8312            Some(1),
8313            0,
8314            "main".to_string(),
8315        ))
8316    }
8317
8318    fn result_text(r: &CallToolResult) -> String {
8319        r.content
8320            .iter()
8321            .filter_map(|c| match c {
8322                ContentBlock::Text(t) => Some(t.text.clone()),
8323                _ => None,
8324            })
8325            .collect::<Vec<_>>()
8326            .join("\n")
8327    }
8328
8329    async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
8330        match h.execute_tool(tool, args).await {
8331            Ok(r) => r,
8332            Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
8333        }
8334    }
8335
8336    // ── introspect event_bus output cap (VIC-4) ──────────────────────────────
8337    #[tokio::test]
8338    async fn event_bus_caps_output_to_limit() {
8339        // The full buffers can be tens of thousands of events (megabytes); the action must cap
8340        // output (default 100, newest first) and still report the true total + a truncated flag.
8341        use crate::introspection::CapturedTauriEvent;
8342        let state = state_with(PrivacyConfig::default());
8343        for i in 0..150 {
8344            state.event_bus.push(CapturedTauriEvent {
8345                name: format!("evt-{i}"),
8346                payload: "{}".to_string(),
8347                timestamp: chrono::Utc::now().to_rfc3339(),
8348            });
8349        }
8350        let h = VictauriMcpHandler::new(state, Arc::new(RecordingBridge::default()));
8351
8352        // Default limit (100).
8353        let r = call(&h, "introspect", json!({"action": "event_bus"})).await;
8354        let v: serde_json::Value = serde_json::from_str(&result_text(&r)).unwrap();
8355        assert_eq!(
8356            v["tauri_events"]["count"], 150,
8357            "true total must be reported"
8358        );
8359        assert_eq!(v["tauri_events"]["returned"], 100, "default cap is 100");
8360        assert_eq!(v["tauri_events"]["truncated"], true);
8361        assert_eq!(v["tauri_events"]["events"].as_array().unwrap().len(), 100);
8362
8363        // Explicit smaller limit (passed via the generic `args` object).
8364        let r = call(
8365            &h,
8366            "introspect",
8367            json!({"action": "event_bus", "args": {"limit": 10}}),
8368        )
8369        .await;
8370        let v: serde_json::Value = serde_json::from_str(&result_text(&r)).unwrap();
8371        assert_eq!(v["tauri_events"]["returned"], 10);
8372        assert_eq!(v["tauri_events"]["events"].as_array().unwrap().len(), 10);
8373    }
8374
8375    // ── recording.replay (audit #30/#31, A1) ─────────────────────────────────
8376
8377    #[tokio::test]
8378    async fn replay_never_invokes_a_blocklisted_command() {
8379        let bridge = RecordingBridge::default();
8380        let state = state_with(blocking(&["delete_account"]));
8381        state.recorder.start("s1".to_string()).unwrap();
8382        state.recorder.record_event(ipc_event("delete_account"));
8383        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8384
8385        let r = call(&h, "recording", json!({"action": "replay"})).await;
8386
8387        assert!(
8388            !bridge.invoked("delete_account"),
8389            "SIDE-EFFECT LEAK: replay handed a blocklisted command's invoke to the bridge (audit #30/#31)"
8390        );
8391        assert!(
8392            result_text(&r).contains("blocked"),
8393            "replay should report the command as blocked, got: {}",
8394            result_text(&r)
8395        );
8396    }
8397
8398    #[tokio::test]
8399    async fn replay_does_invoke_an_allowed_command() {
8400        // Positive control: proves the negative test isn't vacuous (the path really
8401        // reaches the bridge for a permitted command).
8402        let state = state_with(PrivacyConfig::default());
8403        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8404        state.recorder.start("s1".to_string()).unwrap();
8405        state.recorder.record_event(ipc_event("greet"));
8406        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8407
8408        let _ = call(&h, "recording", json!({"action": "replay"})).await;
8409
8410        assert!(
8411            bridge.invoked("greet"),
8412            "positive control failed: an ALLOWED command was not invoked, so the negative test proves nothing"
8413        );
8414    }
8415
8416    #[tokio::test]
8417    async fn imported_session_cannot_invoke_a_blocklisted_command() {
8418        // audit #31: a crafted session handed to an agent ("replay this to reproduce")
8419        // must not become arbitrary command invocation.
8420        let bridge = RecordingBridge::default();
8421        let state = state_with(blocking(&["wipe_database"]));
8422        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8423
8424        let session = RecordedSession::new(
8425            "poisoned".to_string(),
8426            chrono::Utc::now(),
8427            vec![RecordedEvent::new(
8428                0,
8429                chrono::Utc::now(),
8430                ipc_event("wipe_database"),
8431            )],
8432            Vec::new(),
8433        );
8434        let session_json = serde_json::to_string(&session).unwrap();
8435
8436        let imp = call(
8437            &h,
8438            "recording",
8439            json!({"action": "import", "session_json": session_json}),
8440        )
8441        .await;
8442        assert_ne!(
8443            imp.is_error,
8444            Some(true),
8445            "import itself should succeed: {}",
8446            result_text(&imp)
8447        );
8448
8449        let r = call(&h, "recording", json!({"action": "replay"})).await;
8450        assert!(
8451            !bridge.invoked("wipe_database"),
8452            "SIDE-EFFECT LEAK: an imported session replayed a blocklisted command (audit #31)"
8453        );
8454        assert!(result_text(&r).contains("blocked"));
8455    }
8456
8457    #[tokio::test]
8458    async fn replay_skips_calls_it_cannot_reproduce_faithfully() {
8459        // Recordings do not capture arguments: re-invoking `delete_todo({id:3})` as
8460        // `delete_todo()` is guaranteed wrong, and re-running a failed/pending call reproduces
8461        // nothing. Only successful no-arg calls are replayed.
8462        let state = state_with(PrivacyConfig::default());
8463        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8464        state.recorder.start("s".to_string()).unwrap();
8465        let AppEvent::Ipc(mut with_args) = ipc_event("delete_todo") else {
8466            unreachable!()
8467        };
8468        with_args.arg_size_bytes = 8;
8469        state.recorder.record_event(AppEvent::Ipc(with_args));
8470        let AppEvent::Ipc(mut failed) = ipc_event("flaky") else {
8471            unreachable!()
8472        };
8473        failed.result = IpcResult::Err("boom".to_string());
8474        state.recorder.record_event(AppEvent::Ipc(failed));
8475        state.recorder.record_event(ipc_event("get_counter"));
8476        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8477
8478        let r = call(&h, "recording", json!({"action": "replay"})).await;
8479        let text = result_text(&r);
8480        assert!(
8481            !bridge.invoked("delete_todo"),
8482            "a call with args must not be replayed"
8483        );
8484        assert!(
8485            !bridge.invoked("flaky"),
8486            "a failed call must not be replayed"
8487        );
8488        assert!(
8489            bridge.invoked("get_counter"),
8490            "a successful no-arg call is replayed"
8491        );
8492        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
8493        assert_eq!(v["skipped"], 2, "{text}");
8494        assert_eq!(v["replayed"], 1, "{text}");
8495    }
8496
8497    /// A state whose eval timeout is long enough that only the window/shutdown watch can end
8498    /// an unanswered eval quickly.
8499    fn slow_eval_state() -> Arc<VictauriState> {
8500        let Ok(mut s) = Arc::try_unwrap(state_with(PrivacyConfig::default())) else {
8501            unreachable!("fresh Arc has one owner")
8502        };
8503        s.eval_timeout = std::time::Duration::from_secs(20);
8504        Arc::new(s)
8505    }
8506
8507    #[tokio::test]
8508    async fn eval_reports_a_window_closed_mid_call_instead_of_timing_out() {
8509        // The RecordingBridge answers the liveness probe, never the user code, and lists NO
8510        // windows — i.e. the target window is gone while the call is in flight (what a
8511        // command that closes its own window produces).
8512        let state = slow_eval_state();
8513        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8514        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
8515        let started = std::time::Instant::now();
8516        let r = call(
8517            &h,
8518            "eval_js",
8519            json!({"code": "await new Promise(() => {})", "webview_label": "popup"}),
8520        )
8521        .await;
8522        let text = result_text(&r);
8523        assert_eq!(r.is_error, Some(true), "{text}");
8524        assert!(
8525            text.contains("was closed while the call was in flight"),
8526            "{text}"
8527        );
8528        assert!(
8529            started.elapsed() < std::time::Duration::from_secs(5),
8530            "must not wait out the 20s eval timeout (took {:?})",
8531            started.elapsed()
8532        );
8533    }
8534
8535    /// A bridge whose UI thread never answers a window listing (a busy/wedged UI), but which
8536    /// otherwise behaves like `RecordingBridge`.
8537    struct WedgedListingBridge(RecordingBridge);
8538
8539    impl WebviewBridge for WedgedListingBridge {
8540        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
8541            self.0.eval_webview(label, script)
8542        }
8543        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
8544            self.0.get_window_states(label)
8545        }
8546        fn list_window_labels(&self) -> Vec<String> {
8547            Vec::new()
8548        }
8549        fn try_list_window_labels(&self) -> Result<Vec<String>, String> {
8550            Err("list_window_labels did not complete on the main thread: timed out".to_string())
8551        }
8552        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
8553            self.0.get_native_handle(label)
8554        }
8555        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
8556            self.0.manage_window(label, action)
8557        }
8558        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
8559            self.0.resize_window(label, w, h)
8560        }
8561        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
8562            self.0.move_window(label, x, y)
8563        }
8564        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
8565            self.0.set_window_title(label, title)
8566        }
8567    }
8568
8569    #[tokio::test]
8570    async fn page_originated_evals_cannot_starve_agent_evals() {
8571        // Red-team: page JS can call victauri_eval_js; with one shared pool it could park
8572        // never-resolving evals in all 100 slots and fail every agent eval.
8573        let state = state_with(PrivacyConfig::default());
8574        let mut held = Vec::new();
8575        for i in 0..crate::tools::MAX_PAGE_PENDING_EVALS {
8576            let (tx, rx) = tokio::sync::oneshot::channel();
8577            // Spread over windows so the page-wide budget, not a window's, is what fills up.
8578            let label = format!("w{}", i % 5);
8579            let slot = crate::tools::reserve_page_eval(&state, &label, tx)
8580                .await
8581                .unwrap();
8582            held.push((slot, rx));
8583        }
8584        let (tx, _rx) = tokio::sync::oneshot::channel();
8585        let over = crate::tools::reserve_page_eval(&state, "w9", tx).await;
8586        assert!(over.err().unwrap().contains("page-originated"));
8587        // The agent path still has room.
8588        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8589        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
8590        let (tx, _rx2) = tokio::sync::oneshot::channel();
8591        assert!(h.reserve_pending("agent-1", tx).await.is_ok());
8592        drop(held);
8593    }
8594
8595    #[tokio::test]
8596    async fn one_window_cannot_starve_another_windows_page_evals() {
8597        let state = state_with(PrivacyConfig::default());
8598        let mut held = Vec::new();
8599        let mut refused = None;
8600        // Window "a" (whose label is a prefix of "a:b") fills its own budget and no more.
8601        for _ in 0..=crate::tools::MAX_PAGE_PENDING_EVALS_PER_WINDOW {
8602            let (tx, rx) = tokio::sync::oneshot::channel();
8603            match crate::tools::reserve_page_eval(&state, "a", tx).await {
8604                Ok(slot) => held.push((slot, rx)),
8605                Err(e) => refused = Some(e),
8606            }
8607        }
8608        assert_eq!(held.len(), crate::tools::MAX_PAGE_PENDING_EVALS_PER_WINDOW);
8609        assert!(refused.unwrap().contains("window 'a'"));
8610        let (tx, _rx) = tokio::sync::oneshot::channel();
8611        let other = crate::tools::reserve_page_eval(&state, "a:b", tx).await;
8612        assert!(
8613            other.is_ok(),
8614            "window 'a:b' was starved by window 'a': {:?}",
8615            other.as_ref().err()
8616        );
8617        // Dropping the slots releases them.
8618        drop(held);
8619        drop(other);
8620        assert!(state.pending_evals.lock().await.is_empty());
8621    }
8622
8623    #[tokio::test]
8624    async fn a_busy_ui_is_not_mistaken_for_a_closed_window() {
8625        // Red-team finding: a main-thread listing that times out returned `[]`, which the
8626        // eval watch read as "window closed … it most likely ran — do not re-run it". A
8627        // listing that FAILS is not evidence; the eval must run to its own timeout instead.
8628        let Ok(mut s) = Arc::try_unwrap(state_with(PrivacyConfig::default())) else {
8629            unreachable!("fresh Arc has one owner")
8630        };
8631        s.eval_timeout = std::time::Duration::from_millis(2500);
8632        let state = Arc::new(s);
8633        let bridge = WedgedListingBridge(RecordingBridge::answering(state.pending_evals.clone()));
8634        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
8635        let r = call(
8636            &h,
8637            "eval_js",
8638            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8639        )
8640        .await;
8641        let text = result_text(&r);
8642        assert!(
8643            !text.contains("was closed"),
8644            "false 'window closed': {text}"
8645        );
8646        assert!(text.contains("timed out"), "{text}");
8647    }
8648
8649    /// `RecordingBridge`, but reporting a live `main` window (so only page loads are in play).
8650    struct MainWindowBridge(RecordingBridge);
8651
8652    impl WebviewBridge for MainWindowBridge {
8653        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
8654            self.0.eval_webview(label, script)
8655        }
8656        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
8657            self.0.get_window_states(label)
8658        }
8659        fn list_window_labels(&self) -> Vec<String> {
8660            vec!["main".to_string()]
8661        }
8662        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
8663            self.0.get_native_handle(label)
8664        }
8665        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
8666            self.0.manage_window(label, action)
8667        }
8668        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
8669            self.0.resize_window(label, w, h)
8670        }
8671        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
8672            self.0.move_window(label, x, y)
8673        }
8674        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
8675            self.0.set_window_title(label, title)
8676        }
8677    }
8678
8679    fn eval_state_with_timeout(ms: u64) -> Arc<VictauriState> {
8680        let Ok(mut s) = Arc::try_unwrap(state_with(PrivacyConfig::default())) else {
8681            unreachable!("fresh Arc has one owner")
8682        };
8683        s.eval_timeout = std::time::Duration::from_millis(ms);
8684        Arc::new(s)
8685    }
8686
8687    #[tokio::test]
8688    async fn eval_fails_fast_when_its_page_reloads() {
8689        let state = eval_state_with_timeout(20_000);
8690        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8691        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
8692        let reloader = state.clone();
8693        tokio::spawn(async move {
8694            tokio::time::sleep(std::time::Duration::from_millis(600)).await;
8695            inner.load_page("page-2"); // the page reloaded
8696            ready_signal(&reloader, "main", "page-2");
8697        });
8698        let started = std::time::Instant::now();
8699        let r = call(
8700            &h,
8701            "eval_js",
8702            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8703        )
8704        .await;
8705        let text = result_text(&r);
8706        assert!(text.contains("loaded a new page"), "{text}");
8707        assert!(
8708            started.elapsed() < std::time::Duration::from_secs(3),
8709            "must not wait out the 20s timeout (took {:?})",
8710            started.elapsed()
8711        );
8712        assert!(
8713            state.pending_evals.lock().await.is_empty(),
8714            "pending entry removed"
8715        );
8716    }
8717
8718    #[tokio::test]
8719    async fn a_late_ready_signal_from_the_same_page_is_not_a_reload() {
8720        // The bridge's ready signal is fire-and-forget at init and can land just after our
8721        // probe; it carries the nonce of the page the eval runs in and is not a reload.
8722        let state = eval_state_with_timeout(1_500);
8723        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8724        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner)));
8725        let late = state.clone();
8726        tokio::spawn(async move {
8727            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8728            ready_signal(&late, "main", "page-1");
8729        });
8730        let r = call(
8731            &h,
8732            "eval_js",
8733            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8734        )
8735        .await;
8736        let text = result_text(&r);
8737        assert!(!text.contains("loaded a new page"), "false reload: {text}");
8738        assert!(text.contains("timed out"), "{text}");
8739    }
8740
8741    #[tokio::test]
8742    async fn invoke_command_honors_timeout_ms_and_skips_aborted_timings() {
8743        let state = eval_state_with_timeout(20_000);
8744        let bridge = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone()));
8745        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
8746        let started = std::time::Instant::now();
8747        let r = call(
8748            &h,
8749            "invoke_command",
8750            json!({"command": "slow_thing", "webview_label": "main", "timeout_ms": 400}),
8751        )
8752        .await;
8753        let text = result_text(&r);
8754        assert!(
8755            started.elapsed() < std::time::Duration::from_secs(3),
8756            "timeout_ms ignored"
8757        );
8758        assert!(text.contains("timed out after 400ms"), "{text}");
8759        assert!(
8760            text.contains("timeout_ms"),
8761            "the error must point at timeout_ms: {text}"
8762        );
8763        assert!(
8764            state.command_timings.stats_for("slow_thing").is_none(),
8765            "a timed-out call is not a command duration"
8766        );
8767    }
8768
8769    #[tokio::test]
8770    async fn eval_reports_app_shutdown_instead_of_timing_out() {
8771        let state = slow_eval_state();
8772        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8773        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
8774        let shutdown = state.clone();
8775        tokio::spawn(async move {
8776            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
8777            let _ = shutdown.shutdown_tx.send(true);
8778        });
8779        let r = call(
8780            &h,
8781            "eval_js",
8782            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8783        )
8784        .await;
8785        let text = result_text(&r);
8786        assert_eq!(r.is_error, Some(true), "{text}");
8787        assert!(text.contains("shutting down"), "{text}");
8788        assert!(
8789            state.pending_evals.lock().await.is_empty(),
8790            "pending entry removed"
8791        );
8792    }
8793
8794    /// The bridge's ready signal for window `label`, from a page whose nonce is `nonce`.
8795    fn ready_signal(state: &VictauriState, label: &str, nonce: &str) {
8796        state.page_loads.record_load(label, Some(nonce));
8797    }
8798
8799    async fn hanging_eval(h: &VictauriMcpHandler) -> (String, std::time::Duration) {
8800        let started = std::time::Instant::now();
8801        let r = call(
8802            h,
8803            "eval_js",
8804            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8805        )
8806        .await;
8807        (result_text(&r), started.elapsed())
8808    }
8809
8810    #[tokio::test]
8811    async fn a_reload_right_after_injection_is_detected() {
8812        // A reload that completes within a few ms of the injection (eval_js("location.reload()"),
8813        // navigate, a fast HMR) was hidden by a 250ms grace and waited out the full timeout.
8814        let state = eval_state_with_timeout(20_000);
8815        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8816        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
8817        let reloader = state.clone();
8818        tokio::spawn(async move {
8819            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8820            inner.load_page("page-2");
8821            ready_signal(&reloader, "main", "page-2");
8822        });
8823        let (text, took) = hanging_eval(&h).await;
8824        assert!(text.contains("loaded a new page"), "{text}");
8825        assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
8826    }
8827
8828    #[tokio::test]
8829    async fn a_same_page_ready_signal_arriving_late_is_not_a_reload() {
8830        // Under load the page's own ready signal can land seconds after the probe; it carries the
8831        // nonce the eval was armed in, so the eval keeps waiting for its real result.
8832        let state = eval_state_with_timeout(3_000);
8833        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8834        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner)));
8835        let late = state.clone();
8836        tokio::spawn(async move {
8837            tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
8838            ready_signal(&late, "main", "page-1");
8839        });
8840        let (text, _) = hanging_eval(&h).await;
8841        assert!(!text.contains("loaded a new page"), "false reload: {text}");
8842        assert!(text.contains("timed out"), "{text}");
8843    }
8844
8845    #[tokio::test]
8846    async fn a_forged_ready_signal_does_not_abort_an_eval() {
8847        // Page script can call victauri_eval_callback('__victauri_bridge_ready__') itself. The
8848        // page did not change (the probe still reports the armed nonce), so it is not a reload.
8849        let state = eval_state_with_timeout(2_500);
8850        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8851        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner)));
8852        let forger = state.clone();
8853        tokio::spawn(async move {
8854            tokio::time::sleep(std::time::Duration::from_millis(600)).await;
8855            ready_signal(&forger, "main", "forged");
8856        });
8857        let (text, _) = hanging_eval(&h).await;
8858        assert!(!text.contains("loaded a new page"), "forged reload: {text}");
8859        assert!(text.contains("timed out"), "{text}");
8860    }
8861
8862    /// R4-EVAL1: a ready signal whose confirming probe FAILS (a busy UI thread, a full slot
8863    /// map) is no evidence of a new page. The eval used to abort as "loaded a new page" — which
8864    /// page script can provoke with a forged signal while the UI is busy, and which invites a
8865    /// retry that runs side-effecting code twice. It must keep waiting (its own timeout bounds
8866    /// it).
8867    #[tokio::test]
8868    async fn an_unconfirmed_ready_signal_does_not_abort_an_eval() {
8869        let state = eval_state_with_timeout(4_000);
8870        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8871        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
8872        let signal = state.clone();
8873        tokio::spawn(async move {
8874            tokio::time::sleep(std::time::Duration::from_millis(300)).await;
8875            inner.probe_silent.store(true, Ordering::SeqCst); // the UI stops answering
8876            ready_signal(&signal, "main", "forged");
8877        });
8878        let (text, _) = hanging_eval(&h).await;
8879        assert!(
8880            !text.contains("loaded a new page"),
8881            "aborted on no evidence: {text}"
8882        );
8883        assert!(text.contains("timed out"), "{text}");
8884    }
8885
8886    /// B-L5: the timeout message rules out a parse error only when the fast parse check was
8887    /// actually armed (the page reported a nonce) and delivered; otherwise it cannot know.
8888    #[tokio::test]
8889    async fn a_timeout_claims_no_parse_error_only_when_the_parse_check_ran() {
8890        let state = eval_state_with_timeout(300);
8891        // No nonce from the probe: the parse check is disabled.
8892        let h = VictauriMcpHandler::new(
8893            state.clone(),
8894            Arc::new(RecordingBridge::answering(state.pending_evals.clone())),
8895        );
8896        let (text, _) = hanging_eval(&h).await;
8897        assert!(text.contains("timed out"), "{text}");
8898        assert!(!text.contains("NOT a parse"), "unfounded claim: {text}");
8899        assert!(text.contains("could not be ruled out"), "{text}");
8900        // Armed and delivered: the claim holds.
8901        let h = VictauriMcpHandler::new(
8902            state.clone(),
8903            Arc::new(RecordingBridge::in_page(state.pending_evals.clone(), "p1")),
8904        );
8905        let (text, _) = hanging_eval(&h).await;
8906        assert!(text.contains("NOT a parse error"), "{text}");
8907        // Armed but the check could not be delivered.
8908        let h = VictauriMcpHandler::new(
8909            state.clone(),
8910            Arc::new(CheckNotDeliveredBridge(RecordingBridge::in_page(
8911                state.pending_evals.clone(),
8912                "p1",
8913            ))),
8914        );
8915        let (text, _) = hanging_eval(&h).await;
8916        assert!(!text.contains("NOT a parse"), "unfounded claim: {text}");
8917    }
8918
8919    /// Delivers everything but the eval's parse-check script.
8920    struct CheckNotDeliveredBridge(RecordingBridge);
8921
8922    impl WebviewBridge for CheckNotDeliveredBridge {
8923        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
8924            if script.contains("_evalCheck") {
8925                return Err("window busy".to_string());
8926            }
8927            self.0.eval_webview(label, script)
8928        }
8929        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
8930            self.0.get_window_states(label)
8931        }
8932        fn list_window_labels(&self) -> Vec<String> {
8933            vec!["main".to_string()]
8934        }
8935        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
8936            self.0.get_native_handle(label)
8937        }
8938        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
8939            self.0.manage_window(label, action)
8940        }
8941        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
8942            self.0.resize_window(label, w, h)
8943        }
8944        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
8945            self.0.move_window(label, x, y)
8946        }
8947        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
8948            self.0.set_window_title(label, title)
8949        }
8950    }
8951
8952    /// A page that answers WITHOUT the armed nonce (the bridge that armed the eval is gone)
8953    /// is positive evidence of a new page.
8954    #[tokio::test]
8955    async fn a_page_without_the_armed_nonce_is_a_reload() {
8956        let state = eval_state_with_timeout(20_000);
8957        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8958        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
8959        let reloader = state.clone();
8960        tokio::spawn(async move {
8961            tokio::time::sleep(std::time::Duration::from_millis(300)).await;
8962            *inner
8963                .page_nonce
8964                .lock()
8965                .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
8966            ready_signal(&reloader, "main", "whatever");
8967        });
8968        let (text, took) = hanging_eval(&h).await;
8969        assert!(text.contains("loaded a new page"), "{text}");
8970        assert!(took < std::time::Duration::from_secs(5), "took {took:?}");
8971    }
8972
8973    #[tokio::test]
8974    async fn an_eval_started_after_app_exit_fails_fast() {
8975        // `subscribe()` marks the current value seen: an eval started after the exit signal
8976        // waited its whole timeout for a change that had already happened.
8977        let state = slow_eval_state();
8978        state.shutdown_tx.send_replace(true);
8979        let bridge = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone()));
8980        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
8981        let (text, took) =
8982            tokio::time::timeout(std::time::Duration::from_secs(10), hanging_eval(&h))
8983                .await
8984                .expect("an eval after app exit must not wait out its 20s timeout");
8985        assert!(text.contains("shutting down"), "{text}");
8986        assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
8987    }
8988
8989    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8990    async fn a_dropped_eval_call_releases_its_pending_slot() {
8991        // REST runs the tool inside the axum future: a client that disconnects or times out
8992        // drops it mid-wait. The pending entry used to leak; 100 of them wedged every eval.
8993        let state = eval_state_with_timeout(20_000);
8994        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8995        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
8996        for _ in 0..3 {
8997            let dropped = tokio::time::timeout(
8998                std::time::Duration::from_millis(300),
8999                h.execute_tool("eval_js", json!({"code": "await new Promise(() => {})"})),
9000            )
9001            .await;
9002            assert!(
9003                dropped.is_err(),
9004                "the call must still be in flight when dropped"
9005            );
9006        }
9007        // The recording drain reserves slots too (its page never answers here). It only reads
9008        // while a recording is active and its drain epoch is set, as `recording start` does.
9009        let generation = state
9010            .recorder
9011            .start_session("slot-release".to_string())
9012            .unwrap();
9013        state.drain_watermarks.reset(0.0, generation);
9014        let bridge: Arc<dyn WebviewBridge> = Arc::new(RecordingBridge::default());
9015        let dropped = tokio::time::timeout(
9016            std::time::Duration::from_millis(300),
9017            drain_window_into_recording(&state, &bridge, "main"),
9018        )
9019        .await;
9020        assert!(
9021            dropped.is_err(),
9022            "the drain must still be in flight when dropped"
9023        );
9024        // Removal on drop may be deferred to a task when the map is momentarily locked.
9025        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9026        assert!(
9027            state.pending_evals.lock().await.is_empty(),
9028            "a dropped call leaked its pending-eval slot"
9029        );
9030    }
9031
9032    /// Answers the liveness probe, but every other script fails to inject.
9033    struct InjectFailsBridge(RecordingBridge);
9034
9035    impl WebviewBridge for InjectFailsBridge {
9036        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
9037            self.0.eval_webview(label, script)?;
9038            if script.contains("probe_ok") {
9039                Ok(())
9040            } else {
9041                Err("window not found: main".to_string())
9042            }
9043        }
9044        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9045            self.0.get_window_states(label)
9046        }
9047        fn list_window_labels(&self) -> Vec<String> {
9048            vec!["main".to_string()]
9049        }
9050        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
9051            self.0.get_native_handle(label)
9052        }
9053        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
9054            self.0.manage_window(label, action)
9055        }
9056        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
9057            self.0.resize_window(label, w, h)
9058        }
9059        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
9060            self.0.move_window(label, x, y)
9061        }
9062        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
9063            self.0.set_window_title(label, title)
9064        }
9065    }
9066
9067    #[tokio::test]
9068    async fn invoke_command_records_no_timing_for_calls_that_never_ran() {
9069        // Only a call that reached the command is a command duration: a saturated pending map
9070        // (~0ms), a dead bridge (~2s probe) or a failed injection measure nothing about it.
9071        let state = eval_state_with_timeout(1_000);
9072        let fillers: Vec<_> = (0..MAX_PENDING_EVALS)
9073            .map(|_| tokio::sync::oneshot::channel::<String>())
9074            .collect();
9075        {
9076            let mut p = state.pending_evals.lock().await;
9077            for (i, (tx, _)) in fillers.into_iter().enumerate() {
9078                p.insert(format!("filler-{i}"), tx);
9079            }
9080        }
9081        let answering = RecordingBridge::answering(state.pending_evals.clone());
9082        let h = VictauriMcpHandler::new(state.clone(), Arc::new(answering.clone()));
9083        let r = call(&h, "invoke_command", json!({"command": "saturated"})).await;
9084        assert!(
9085            result_text(&r).contains("too many concurrent"),
9086            "{}",
9087            result_text(&r)
9088        );
9089        state.pending_evals.lock().await.clear();
9090
9091        let dead = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
9092        let r = call(&dead, "invoke_command", json!({"command": "dead_bridge"})).await;
9093        assert!(
9094            result_text(&r).contains("bridge not responding"),
9095            "{}",
9096            result_text(&r)
9097        );
9098
9099        let failing = VictauriMcpHandler::new(
9100            state.clone(),
9101            Arc::new(InjectFailsBridge(RecordingBridge::answering(
9102                state.pending_evals.clone(),
9103            ))),
9104        );
9105        let r = call(
9106            &failing,
9107            "invoke_command",
9108            json!({"command": "not_injected"}),
9109        )
9110        .await;
9111        assert!(
9112            result_text(&r).contains("injection failed"),
9113            "{}",
9114            result_text(&r)
9115        );
9116
9117        for cmd in ["saturated", "dead_bridge", "not_injected"] {
9118            assert!(
9119                state.command_timings.stats_for(cmd).is_none(),
9120                "'{cmd}' never ran but was recorded as a command timing"
9121            );
9122        }
9123
9124        // Code that did not parse never ran either.
9125        answering.answer_evals_with(r#"{"__victauri_not_run":"did not begin executing"}"#);
9126        let r = call(&h, "invoke_command", json!({"command": "never_parsed"})).await;
9127        assert!(
9128            result_text(&r).contains("parse error"),
9129            "{}",
9130            result_text(&r)
9131        );
9132        assert!(state.command_timings.stats_for("never_parsed").is_none());
9133
9134        // Positive control: a call that ran (and threw) IS a timing.
9135        answering.answer_evals_with(r#"{"__victauri_err":"boom"}"#);
9136        let r = call(&h, "invoke_command", json!({"command": "ran_and_threw"})).await;
9137        assert!(result_text(&r).contains("boom"), "{}", result_text(&r));
9138        assert!(state.command_timings.stats_for("ran_and_threw").is_some());
9139    }
9140
9141    #[tokio::test]
9142    async fn a_trusted_key_press_stops_when_its_element_cannot_be_focused() {
9143        // The focus result used to be ignored and the OS key sent regardless — into whatever
9144        // element (or app) held focus.
9145        let state = eval_state_with_timeout(2_000);
9146        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9147        bridge.answer_evals_with(r#"{"__victauri_ok":false,"__victauri_type":"value"}"#);
9148        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9149        let r = call(
9150            &h,
9151            "input",
9152            json!({"action": "press_key", "key": "Enter", "ref_id": "e9", "trusted": true}),
9153        )
9154        .await;
9155        let text = result_text(&r);
9156        assert_eq!(r.is_error, Some(true), "{text}");
9157        assert!(text.contains("not focusable"), "key sent anyway: {text}");
9158    }
9159
9160    /// G-12: `route add` forwarded any `delay_ms` (a u64) to the page, where a delayed request
9161    /// is held that long. It is capped like a `fault` delay, refused before reaching the page.
9162    #[tokio::test]
9163    async fn route_delay_is_capped() {
9164        let state = eval_state_with_timeout(2_000);
9165        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9166        bridge.answer_evals_with(&ok_envelope(&json!({"ok": true, "id": 1})));
9167        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9168        let added = |b: &RecordingBridge| {
9169            b.scripts
9170                .lock()
9171                .unwrap_or_else(std::sync::PoisonError::into_inner)
9172                .iter()
9173                .filter(|s| s.contains("addRoute("))
9174                .count()
9175        };
9176        let r = call(
9177            &h,
9178            "route",
9179            json!({"action": "add", "pattern": "/api", "behavior": "delay",
9180                   "delay_ms": MAX_FAULT_DELAY_MS + 1}),
9181        )
9182        .await;
9183        let text = result_text(&r);
9184        assert_eq!(r.is_error, Some(true), "{text}");
9185        assert!(text.contains("delay_ms"), "{text}");
9186        assert_eq!(added(&bridge), 0, "the rule must not reach the page");
9187        // At the cap it is accepted.
9188        let r = call(
9189            &h,
9190            "route",
9191            json!({"action": "add", "pattern": "/api", "behavior": "delay",
9192                   "delay_ms": MAX_FAULT_DELAY_MS}),
9193        )
9194        .await;
9195        assert_ne!(r.is_error, Some(true), "{}", result_text(&r));
9196        assert_eq!(added(&bridge), 1);
9197    }
9198
9199    /// R5-ANIM1: `animation scrub capture=true` reported `"captured": true` with no filmstrip
9200    /// when no frame could be captured (here: no native window handle), hiding the failure.
9201    #[tokio::test]
9202    async fn animation_scrub_never_claims_a_capture_it_did_not_make() {
9203        let state = eval_state_with_timeout(2_000);
9204        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9205        bridge.answer_evals_with(&ok_envelope(
9206            &json!({"prepared": true, "duration": 100, "anim_count": 1, "t": 0}),
9207        ));
9208        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9209        let r = call(
9210            &h,
9211            "animation",
9212            json!({"action": "scrub", "selector": "#toast", "points": 2, "capture": true}),
9213        )
9214        .await;
9215        let text = result_text(&r);
9216        assert_ne!(
9217            r.is_error,
9218            Some(true),
9219            "the geometry curve is still returned: {text}"
9220        );
9221        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
9222        assert_eq!(v["captured"], false, "{v}");
9223        assert!(v.get("filmstrip").is_none(), "{v}");
9224        assert!(
9225            v["capture_error"]
9226                .as_str()
9227                .is_some_and(|e| e.contains("no handle")),
9228            "the capture failure must be surfaced: {v}"
9229        );
9230        assert_eq!(v["curve"].as_array().map(Vec::len), Some(2), "{v}");
9231    }
9232
9233    /// The eval envelope for a page result `value`.
9234    /// R5-JS5: `limit: 0` means "return at most zero entries". The log JS used `.slice(-0)`
9235    /// (and the bridge treats a falsy limit as "all"), so it returned EVERY entry.
9236    #[tokio::test]
9237    async fn log_limit_zero_returns_no_entries() {
9238        let state = eval_state_with_timeout(2_000);
9239        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9240        bridge.answer_evals_with(&ok_envelope(&json!([{"a": 1}, {"a": 2}])));
9241        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9242        for action in [
9243            "console",
9244            "network",
9245            "ipc",
9246            "navigation",
9247            "dialogs",
9248            "events",
9249        ] {
9250            let r = call(&h, "logs", json!({"action": action, "limit": 0})).await;
9251            let text = result_text(&r);
9252            assert_ne!(r.is_error, Some(true), "{action}: {text}");
9253            let v: serde_json::Value = serde_json::from_str(&text)
9254                .unwrap_or_else(|e| panic!("{action}: not JSON ({e}): {text}"));
9255            assert_eq!(
9256                v,
9257                json!([]),
9258                "logs {action} limit=0 returned entries: {text}"
9259            );
9260        }
9261        let r = call(&h, "route", json!({"action": "matches", "limit": 0})).await;
9262        let text = result_text(&r);
9263        let v: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
9264        assert_eq!(
9265            v,
9266            json!([]),
9267            "route matches limit=0 returned entries: {text}"
9268        );
9269    }
9270
9271    fn ok_envelope(value: &serde_json::Value) -> String {
9272        json!({"__victauri_ok": value, "__victauri_type": "object"}).to_string()
9273    }
9274
9275    /// R4-IN1: trusted typing / key presses go out only when the page confirms focus landed
9276    /// on the element; an element that exists but did not take focus stops the input.
9277    #[tokio::test]
9278    async fn trusted_keys_are_sent_only_when_focus_landed_on_the_element() {
9279        let state = eval_state_with_timeout(2_000);
9280        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9281        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9282        let type_args =
9283            json!({"action": "type_text", "ref_id": "e3", "text": "hi", "trusted": true});
9284        let key_args =
9285            json!({"action": "press_key", "key": "Enter", "ref_id": "e3", "trusted": true});
9286
9287        bridge.answer_evals_with(&ok_envelope(&json!({"found": true, "focused": false})));
9288        for args in [&type_args, &key_args] {
9289            let r = call(&h, "input", args.clone()).await;
9290            let text = result_text(&r);
9291            assert_eq!(r.is_error, Some(true), "{args}: {text}");
9292            assert!(text.contains("focus did not land"), "{args}: {text}");
9293        }
9294        bridge.answer_evals_with(&ok_envelope(&json!({"found": false, "focused": false})));
9295        let r = call(&h, "input", type_args.clone()).await;
9296        assert!(
9297            result_text(&r).contains("ref not found"),
9298            "{}",
9299            result_text(&r)
9300        );
9301        assert!(
9302            bridge.natives().is_empty(),
9303            "keys sent: {:?}",
9304            bridge.natives()
9305        );
9306
9307        // Positive control: confirmed focus → the OS input goes out.
9308        bridge.answer_evals_with(&ok_envelope(&json!({"found": true, "focused": true})));
9309        for args in [&type_args, &key_args] {
9310            let r = call(&h, "input", args.clone()).await;
9311            assert_ne!(r.is_error, Some(true), "{args}: {}", result_text(&r));
9312        }
9313        assert_eq!(bridge.natives(), vec!["type hi", "key Enter"]);
9314    }
9315
9316    /// R4-IN2: a trusted click is sent only at a point the page vouched for, and never at an
9317    /// unusable one.
9318    #[tokio::test]
9319    async fn trusted_click_is_refused_unless_the_page_reports_a_clickable_point() {
9320        let state = eval_state_with_timeout(2_000);
9321        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9322        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9323        let args = json!({"action": "click", "ref_id": "e5", "trusted": true});
9324        for (answer, expect) in [
9325            (
9326                json!({"error": "element is covered at its center point by <div>"}),
9327                "covered",
9328            ),
9329            (json!({"x": -4.0, "y": 10.0}), "unusable click point"),
9330            (json!(null), "ref not found"),
9331        ] {
9332            bridge.answer_evals_with(&ok_envelope(&answer));
9333            let r = call(&h, "interact", args.clone()).await;
9334            let text = result_text(&r);
9335            assert_eq!(r.is_error, Some(true), "{answer}: {text}");
9336            assert!(text.contains(expect), "{answer}: {text}");
9337        }
9338        assert!(
9339            bridge.natives().is_empty(),
9340            "clicked: {:?}",
9341            bridge.natives()
9342        );
9343        bridge.answer_evals_with(&ok_envelope(&json!({"x": 150.0, "y": 226.0})));
9344        let r = call(&h, "interact", args).await;
9345        assert_ne!(r.is_error, Some(true), "{}", result_text(&r));
9346        assert_eq!(bridge.natives(), vec!["click 150,226"]);
9347    }
9348
9349    #[tokio::test]
9350    async fn an_unserializable_result_is_reported_as_code_that_ran() {
9351        // A circular object / BigInt result used to read "JavaScript error: …" although the code
9352        // ran — an agent then re-ran side-effecting code to "fix" it.
9353        let state = eval_state_with_timeout(2_000);
9354        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9355        bridge.answer_evals_with(
9356            r#"{"__victauri_unserializable":"Do not know how to serialize a BigInt"}"#,
9357        );
9358        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9359        let r = call(&h, "eval_js", json!({"code": "return 1n"})).await;
9360        let text = result_text(&r);
9361        assert_eq!(r.is_error, Some(true), "{text}");
9362        assert!(text.contains("the code ran"), "{text}");
9363        assert!(text.contains("BigInt"), "{text}");
9364        assert!(!text.contains("JavaScript error"), "{text}");
9365    }
9366
9367    #[tokio::test]
9368    async fn trace_stop_never_stops_a_recording_it_did_not_start() {
9369        let state = state_with(PrivacyConfig::default());
9370        let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
9371        let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await;
9372        let traced = state
9373            .recorder
9374            .active_session_id()
9375            .expect("trace started a recording");
9376        // The agent ends the trace's recording and starts its own.
9377        let _ = state.recorder.stop();
9378        state.recorder.start("mine".to_string()).unwrap();
9379        let _ = call(&h, "trace", json!({"action": "stop"})).await;
9380        assert_eq!(
9381            state.recorder.active_session_id().as_deref(),
9382            Some("mine"),
9383            "trace stop must not end a recording it did not start (it started {traced})"
9384        );
9385    }
9386
9387    #[tokio::test]
9388    async fn restarting_a_trace_does_not_orphan_its_recording() {
9389        let state = state_with(PrivacyConfig::default());
9390        let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
9391        let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await;
9392        let first = state.recorder.active_session_id().unwrap();
9393        let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await;
9394        let second = state.recorder.active_session_id().unwrap();
9395        assert_ne!(
9396            first, second,
9397            "the first trace's recording was superseded, not orphaned"
9398        );
9399        let _ = call(&h, "trace", json!({"action": "stop"})).await;
9400        assert!(
9401            !state.recorder.is_recording(),
9402            "stop ends the second trace's recording"
9403        );
9404    }
9405
9406    #[tokio::test]
9407    async fn import_refuses_to_discard_an_active_recording() {
9408        let state = state_with(PrivacyConfig::default());
9409        state.recorder.start("live".to_string()).unwrap();
9410        let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
9411        let session = RecordedSession::new(
9412            "other".to_string(),
9413            chrono::Utc::now(),
9414            Vec::new(),
9415            Vec::new(),
9416        );
9417        let r = call(
9418            &h,
9419            "recording",
9420            json!({"action": "import", "session_json": serde_json::to_string(&session).unwrap()}),
9421        )
9422        .await;
9423        assert_eq!(r.is_error, Some(true), "{}", result_text(&r));
9424        assert_eq!(
9425            state.recorder.export().unwrap().id,
9426            "live",
9427            "active recording kept"
9428        );
9429    }
9430
9431    // ── introspect.contract_record / contract_check (audit #30, A2) ───────────
9432
9433    #[tokio::test]
9434    async fn contract_record_never_invokes_a_blocklisted_command() {
9435        let bridge = RecordingBridge::default();
9436        let state = state_with(blocking(&["delete_account"]));
9437        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9438
9439        let r = call(
9440            &h,
9441            "introspect",
9442            json!({"action": "contract_record", "command": "delete_account", "args": {"confirm": true}}),
9443        )
9444        .await;
9445
9446        assert!(
9447            !bridge.invoked("delete_account"),
9448            "SIDE-EFFECT LEAK: contract_record invoked a blocklisted command (audit #30)"
9449        );
9450        assert_eq!(r.is_error, Some(true));
9451        assert!(
9452            result_text(&r).contains("blocked by privacy configuration"),
9453            "got: {}",
9454            result_text(&r)
9455        );
9456    }
9457
9458    #[tokio::test]
9459    async fn contract_record_does_invoke_an_allowed_command() {
9460        let state = state_with(PrivacyConfig::default());
9461        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9462        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9463
9464        let _ = call(
9465            &h,
9466            "introspect",
9467            json!({"action": "contract_record", "command": "get_settings"}),
9468        )
9469        .await;
9470
9471        assert!(
9472            bridge.invoked("get_settings"),
9473            "positive control failed: contract_record did not invoke an allowed command"
9474        );
9475    }
9476
9477    /// R4-PANIC1: the stored sample was cut with `&s[..4096]`, which panics when byte 4096
9478    /// falls inside a multi-byte character — any non-ASCII response over 4 KiB.
9479    #[tokio::test]
9480    async fn contract_record_samples_a_large_non_ascii_response_without_panicking() {
9481        let state = eval_state_with_timeout(2_000);
9482        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9483        // The result text is `"ééé…"`: the opening quote puts every `é` on an odd byte
9484        // offset, so byte 4096 is the second byte of a character.
9485        let payload = "é".repeat(3000);
9486        bridge.answer_evals_with(
9487            &json!({"__victauri_ok": payload, "__victauri_type": "string"}).to_string(),
9488        );
9489        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9490        let r = call(
9491            &h,
9492            "introspect",
9493            json!({"action": "contract_record", "command": "get_notes"}),
9494        )
9495        .await;
9496        let text = result_text(&r);
9497        assert_ne!(r.is_error, Some(true), "{text}");
9498        let baseline = state
9499            .contract_store
9500            .all()
9501            .into_iter()
9502            .find(|b| b.command == "get_notes")
9503            .expect("baseline recorded");
9504        assert!(
9505            baseline.sample.ends_with("...(truncated)"),
9506            "{}",
9507            baseline.sample
9508        );
9509        assert!(baseline.sample.len() <= 4096 + "...(truncated)".len());
9510    }
9511
9512    // ── pending-eval concurrency ceiling (audit: TOCTOU race) ────────────────
9513    #[tokio::test]
9514    async fn reserve_pending_is_a_hard_ceiling_under_concurrency() {
9515        // A check-then-insert (lock, read len(), unlock, …, lock, insert) races: many
9516        // concurrent callers all pass a STALE len() check before any inserts, blowing past
9517        // MAX_PENDING_EVALS. `reserve_pending` checks AND inserts under one lock, so the cap
9518        // is a true ceiling. Pre-fill to MAX-5, fire 50 concurrent reservations: EXACTLY 5
9519        // may succeed and the map must NEVER exceed the cap.
9520        let state = state_with(PrivacyConfig::default());
9521        {
9522            let mut p = state.pending_evals.lock().await;
9523            for i in 0..(MAX_PENDING_EVALS - 5) {
9524                let (tx, _rx) = tokio::sync::oneshot::channel();
9525                p.insert(format!("pre-{i}"), tx);
9526            }
9527        }
9528        let h = Arc::new(VictauriMcpHandler::new(
9529            state.clone(),
9530            Arc::new(RecordingBridge::default()),
9531        ));
9532        let mut tasks = Vec::new();
9533        for i in 0..50 {
9534            let h = h.clone();
9535            tasks.push(tokio::spawn(async move {
9536                let (tx, _rx) = tokio::sync::oneshot::channel();
9537                // keep rx and the slot alive until the reservation has been decided
9538                let slot = h.reserve_pending(&format!("c-{i}"), tx).await.ok();
9539                (slot, _rx)
9540            }));
9541        }
9542        let mut granted = 0;
9543        let mut keep = Vec::new();
9544        for t in tasks {
9545            let (slot, rx) = t.await.unwrap();
9546            if slot.is_some() {
9547                granted += 1;
9548            }
9549            keep.push((slot, rx)); // hold slots so reserved entries are not released
9550        }
9551        let len = state.pending_evals.lock().await.len();
9552        assert!(
9553            len <= MAX_PENDING_EVALS,
9554            "ceiling breached: {len} > {MAX_PENDING_EVALS}"
9555        );
9556        assert_eq!(
9557            granted, 5,
9558            "exactly the 5 free slots should have been reserved, got {granted}"
9559        );
9560        drop(keep);
9561    }
9562
9563    #[tokio::test]
9564    async fn contract_check_never_reinvokes_a_now_blocklisted_command() {
9565        // A baseline recorded before the command was blocked must not be re-invoked
9566        // once the operator adds it to the blocklist (audit #30).
9567        let bridge = RecordingBridge::default();
9568        let state = state_with(blocking(&["delete_account"]));
9569        state
9570            .contract_store
9571            .record(crate::introspection::ContractBaseline {
9572                command: "delete_account".to_string(),
9573                args: json!({}),
9574                shape: crate::introspection::JsonShape::from_value(&json!(true)),
9575                sample: "true".to_string(),
9576                recorded_at: chrono_now(),
9577            });
9578        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9579
9580        let _ = call(&h, "introspect", json!({"action": "contract_check"})).await;
9581
9582        assert!(
9583            !bridge.invoked("delete_account"),
9584            "SIDE-EFFECT LEAK: contract_check re-invoked a now-blocklisted command (audit #30)"
9585        );
9586    }
9587
9588    // ── MCP resources honour the privacy gate (audit B1) ──────────────────────
9589
9590    #[test]
9591    fn resource_reads_are_gated_by_their_mirrored_capability() {
9592        // Resources bypass the tool dispatcher, so the read path must apply the same
9593        // gate. Disabling the capability a resource mirrors must block the resource.
9594        let cfg = PrivacyConfig {
9595            disabled_tools: HashSet::from([
9596                "logs.ipc".to_string(),
9597                "window.list".to_string(),
9598                "get_plugin_info".to_string(),
9599            ]),
9600            ..Default::default()
9601        };
9602        for uri in [
9603            RESOURCE_URI_IPC_LOG,
9604            RESOURCE_URI_WINDOWS,
9605            RESOURCE_URI_STATE,
9606        ] {
9607            let (_, cap) =
9608                resource_required_capability(uri).expect("resource maps to a capability");
9609            assert!(
9610                !cfg.is_tool_enabled(cap),
9611                "disabling capability {cap} must gate resource {uri} (audit B1)"
9612            );
9613            assert!(
9614                !resource_allowed(&cfg, uri),
9615                "disabling capability {cap} must gate resource {uri} (audit B1)"
9616            );
9617        }
9618        // Sanity: with nothing disabled, all three resources read.
9619        let full = PrivacyConfig::default();
9620        for uri in [
9621            RESOURCE_URI_IPC_LOG,
9622            RESOURCE_URI_WINDOWS,
9623            RESOURCE_URI_STATE,
9624        ] {
9625            assert!(full.is_tool_enabled(resource_required_capability(uri).unwrap().1));
9626            assert!(resource_allowed(&full, uri));
9627        }
9628    }
9629
9630    /// One JSON-RPC request to `/mcp` (legacy protocol, so the legacy subscribe method is
9631    /// routed); returns the response body text.
9632    async fn mcp_body(privacy: PrivacyConfig, method: &str, params: serde_json::Value) -> String {
9633        use http_body_util::BodyExt;
9634        use tower::ServiceExt;
9635        let app = build_app(state_with(privacy), Arc::new(RecordingBridge::default()));
9636        let body = json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params});
9637        let req = axum::http::Request::post("/mcp")
9638            .header("host", "127.0.0.1:7373")
9639            .header("content-type", "application/json")
9640            .header("accept", "application/json, text/event-stream")
9641            .header("mcp-protocol-version", "2025-06-18")
9642            .body(axum::body::Body::from(body.to_string()))
9643            .unwrap();
9644        let resp = tokio::time::timeout(std::time::Duration::from_secs(20), app.oneshot(req))
9645            .await
9646            .expect("an MCP request must be answered")
9647            .unwrap();
9648        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
9649        String::from_utf8_lossy(&bytes).into_owned()
9650    }
9651
9652    /// R4-NET3: a resource mirrors a tool action, and disabling the TOOL by its bare name
9653    /// (`disable_tools(["logs"])`) blocks every one of its actions — so it must block the
9654    /// resource too. The gate checked only the capability (`logs.ipc`), so the bare-name
9655    /// disable was ignored for resources (read and the legacy subscribe).
9656    #[tokio::test]
9657    async fn a_bare_tool_disable_also_blocks_its_resources() {
9658        for (disabled, uri) in [
9659            ("logs", RESOURCE_URI_IPC_LOG),
9660            ("window", RESOURCE_URI_WINDOWS),
9661            ("get_plugin_info", RESOURCE_URI_STATE),
9662        ] {
9663            let cfg = || PrivacyConfig {
9664                disabled_tools: HashSet::from([disabled.to_string()]),
9665                ..Default::default()
9666            };
9667            for method in ["resources/read", "resources/subscribe"] {
9668                let body = mcp_body(cfg(), method, json!({"uri": uri})).await;
9669                assert!(
9670                    body.contains("not permitted by the current privacy configuration"),
9671                    "disable_tools([{disabled:?}]) must block {method} {uri}: {body}"
9672                );
9673            }
9674        }
9675        // Positive control: nothing disabled, the resource reads.
9676        let body = mcp_body(
9677            PrivacyConfig::default(),
9678            "resources/read",
9679            json!({"uri": RESOURCE_URI_WINDOWS}),
9680        )
9681        .await;
9682        assert!(
9683            body.contains("\"contents\"") && !body.contains("not permitted"),
9684            "{body}"
9685        );
9686    }
9687
9688    // ── empty/whitespace auth token collapses to NO auth (audit B2) ───────────
9689
9690    #[tokio::test]
9691    async fn empty_auth_token_collapses_to_no_auth() {
9692        use http_body_util::BodyExt;
9693        use tower::ServiceExt;
9694
9695        for token in [Some(String::new()), Some("   ".to_string())] {
9696            let app = crate::mcp::server::build_app_full(
9697                state_with(PrivacyConfig::default()),
9698                Arc::new(RecordingBridge::default()),
9699                token.clone(),
9700                None,
9701            );
9702            let req = axum::extract::Request::builder()
9703                .uri("/info")
9704                .header("host", "127.0.0.1")
9705                .body(axum::body::Body::empty())
9706                .unwrap();
9707            let resp = app.oneshot(req).await.unwrap();
9708            assert_eq!(
9709                resp.status(),
9710                200,
9711                "/info must be reachable with empty token {token:?} (no auth layer)"
9712            );
9713            let bytes = resp.into_body().collect().await.unwrap().to_bytes();
9714            let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9715            assert_eq!(
9716                body["auth_required"],
9717                json!(false),
9718                "empty/whitespace token must report auth_required:false, not looks-protected-isnt (audit B2); token={token:?}"
9719            );
9720        }
9721    }
9722
9723    // ── app_info env allowlist drops secrets (audit #5/B3) ────────────────────
9724
9725    #[test]
9726    fn is_safe_env_key_drops_secrets_keeps_safe() {
9727        for secret in [
9728            "VICTAURI_AUTH_TOKEN",
9729            "TAURI_SIGNING_PRIVATE_KEY",
9730            "TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
9731            "CARGO_REGISTRY_TOKEN",
9732            "AWS_SECRET_ACCESS_KEY",
9733            "DATABASE_DSN",
9734            "GH_PAT",
9735        ] {
9736            assert!(
9737                !is_safe_env_key(secret),
9738                "{secret} is secret-shaped and must NOT be surfaced by app_info (audit #5)"
9739            );
9740        }
9741        for safe in [
9742            "HOME",
9743            "LANG",
9744            "TERM",
9745            "XDG_RUNTIME_DIR",
9746            "TAURI_ENV_PLATFORM",
9747        ] {
9748            assert!(
9749                is_safe_env_key(safe),
9750                "{safe} should be surfaced by app_info"
9751            );
9752        }
9753    }
9754}
9755
9756/// `screenshot` must refuse to "capture" a non-visible window.
9757///
9758/// Live-4DA dogfood (2026-06-16): requesting a hidden window (`label:"briefing"`)
9759/// returned a PNG that was actually the MAIN window's pixels — the OS capture path has
9760/// no live surface for an unmapped window, so it silently yields stale/foreign content.
9761/// The tool now checks visibility first and fails with an actionable message instead.
9762#[cfg(test)]
9763mod screenshot_visibility_tests {
9764    use super::*;
9765    use crate::bridge::WebviewBridge;
9766    use crate::privacy::PrivacyConfig;
9767    use std::collections::HashMap;
9768    use std::sync::Mutex as StdMutex;
9769    use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
9770
9771    fn window(label: &str, visible: bool) -> WindowState {
9772        WindowState::new(label.to_string())
9773            .with_title(label.to_string())
9774            .with_url("http://localhost/".to_string())
9775            .with_visible(visible)
9776            .with_focused(false)
9777            .with_maximized(false)
9778            .with_minimized(false)
9779            .with_fullscreen(false)
9780            .with_position(0, 0)
9781            .with_size(800, 600)
9782    }
9783
9784    /// A bridge with a configurable window set that RECORDS the label `get_native_handle`
9785    /// is asked for, then errs. Recording the label lets a test assert WHICH window the
9786    /// screenshot tool resolved to (the audit-P2 case: omitted label must resolve to a
9787    /// VISIBLE window, never hidden "main"); the error lets a test prove the visibility gate
9788    /// fired *before* the OS-handle path was reached.
9789    struct ConfigBridge {
9790        windows: Vec<WindowState>,
9791        handle_label: Arc<StdMutex<Option<Option<String>>>>,
9792    }
9793
9794    impl ConfigBridge {
9795        fn new(windows: Vec<WindowState>) -> Self {
9796            Self {
9797                windows,
9798                handle_label: Arc::new(StdMutex::new(None)),
9799            }
9800        }
9801        /// The label `get_native_handle` was called with, if it was reached.
9802        /// `Some(Some(l))` = called with label `l`; `Some(None)` = called with the default;
9803        /// `None` = never reached (gate short-circuited).
9804        fn requested_handle(&self) -> Option<Option<String>> {
9805            self.handle_label
9806                .lock()
9807                .unwrap_or_else(std::sync::PoisonError::into_inner)
9808                .clone()
9809        }
9810    }
9811
9812    impl WebviewBridge for ConfigBridge {
9813        fn eval_webview(&self, _l: Option<&str>, _s: &str) -> Result<(), String> {
9814            Err("no eval".to_string())
9815        }
9816        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9817            match label {
9818                Some(l) => self
9819                    .windows
9820                    .iter()
9821                    .filter(|w| w.label == l)
9822                    .cloned()
9823                    .collect(),
9824                None => self.windows.clone(),
9825            }
9826        }
9827        fn list_window_labels(&self) -> Vec<String> {
9828            self.windows.iter().map(|w| w.label.clone()).collect()
9829        }
9830        fn get_native_handle(&self, l: Option<&str>) -> Result<isize, String> {
9831            *self
9832                .handle_label
9833                .lock()
9834                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(l.map(str::to_string));
9835            Err("native handle path reached".to_string())
9836        }
9837        fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
9838            Ok(String::new())
9839        }
9840        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
9841            Ok(())
9842        }
9843        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
9844            Ok(())
9845        }
9846        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
9847            Ok(())
9848        }
9849    }
9850
9851    fn handler_with(bridge: Arc<ConfigBridge>) -> VictauriMcpHandler {
9852        let state = Arc::new(VictauriState {
9853            event_log: EventLog::new(100),
9854            registry: CommandRegistry::new(),
9855            port: std::sync::atomic::AtomicU16::new(0),
9856            pending_evals: Arc::new(Mutex::new(HashMap::new())),
9857            recorder: EventRecorder::new(100),
9858            privacy: PrivacyConfig::default(),
9859            eval_timeout: std::time::Duration::from_millis(100),
9860            shutdown_tx: tokio::sync::watch::channel(false).0,
9861            started_at: std::time::Instant::now(),
9862            tool_invocations: std::sync::atomic::AtomicU64::new(0),
9863            allow_file_navigation: false,
9864            command_timings: crate::introspection::CommandTimings::new(),
9865            fault_registry: crate::introspection::FaultRegistry::new(),
9866            contract_store: crate::introspection::ContractStore::new(),
9867            startup_timeline: crate::introspection::StartupTimeline::new(),
9868            event_bus: crate::introspection::EventBusMonitor::default(),
9869            task_tracker: crate::introspection::TaskTracker::new(),
9870            bridge_ready: std::sync::atomic::AtomicBool::new(true),
9871            bridge_notify: tokio::sync::Notify::new(),
9872            db_search_paths: Vec::new(),
9873            screencast: Arc::new(crate::screencast::Screencast::default()),
9874            probes: crate::introspection::AppStateProbes::default(),
9875            drain_watermarks: crate::introspection::DrainWatermarks::default(),
9876            page_loads: crate::introspection::PageLoads::default(),
9877        });
9878        VictauriMcpHandler::new(state, bridge)
9879    }
9880
9881    fn error_text(r: &CallToolResult) -> String {
9882        r.content
9883            .iter()
9884            .filter_map(|c| match c {
9885                ContentBlock::Text(t) => Some(t.text.clone()),
9886                _ => None,
9887            })
9888            .collect::<Vec<_>>()
9889            .join("\n")
9890    }
9891
9892    #[tokio::test]
9893    async fn hidden_window_screenshot_errors_clearly() {
9894        let bridge = Arc::new(ConfigBridge::new(vec![
9895            window("main", true),
9896            window("briefing", false),
9897        ]));
9898        let h = handler_with(bridge.clone());
9899        let r = h
9900            .screenshot(Parameters(ScreenshotParams {
9901                window_label: Some("briefing".to_string()),
9902            }))
9903            .await;
9904        assert_eq!(r.is_error, Some(true), "hidden window must error");
9905        let text = error_text(&r);
9906        assert!(
9907            text.contains("not visible"),
9908            "error must explain the window is not visible, got: {text}"
9909        );
9910        assert!(
9911            bridge.requested_handle().is_none(),
9912            "must short-circuit BEFORE the OS-handle/capture path"
9913        );
9914    }
9915
9916    #[tokio::test]
9917    async fn visible_window_screenshot_proceeds_to_capture() {
9918        let bridge = Arc::new(ConfigBridge::new(vec![
9919            window("main", true),
9920            window("briefing", false),
9921        ]));
9922        let h = handler_with(bridge.clone());
9923        let r = h
9924            .screenshot(Parameters(ScreenshotParams {
9925                window_label: Some("main".to_string()),
9926            }))
9927            .await;
9928        // The gate must let a visible window THROUGH to the OS-handle path (which this mock
9929        // fails) — proving the gate only blocks hidden windows.
9930        let text = error_text(&r);
9931        assert!(
9932            text.contains("native handle path reached")
9933                || text.contains("cannot get window handle"),
9934            "a visible window must reach the capture path, got: {text}"
9935        );
9936        assert_eq!(
9937            bridge.requested_handle(),
9938            Some(Some("main".to_string())),
9939            "must capture the explicitly requested visible window"
9940        );
9941    }
9942
9943    // GPT audit (P2): `screenshot {}` with NO label previously resolved through
9944    // find_window(None), which prefers "main" UNCONDITIONALLY — so an app that hides main but
9945    // keeps a secondary window visible captured hidden main (the wrong-pixels class the PR
9946    // exists to prevent). The tool must now resolve its own VISIBLE target.
9947    #[tokio::test]
9948    async fn omitted_label_skips_hidden_main_for_visible_secondary() {
9949        let bridge = Arc::new(ConfigBridge::new(vec![
9950            window("main", false),     // main hidden
9951            window("secondary", true), // a different window is visible
9952        ]));
9953        let h = handler_with(bridge.clone());
9954        let r = h
9955            .screenshot(Parameters(ScreenshotParams { window_label: None }))
9956            .await;
9957        let text = error_text(&r);
9958        assert!(
9959            !text.contains("not visible") && !text.contains("no visible window"),
9960            "a visible secondary window exists — must NOT error, got: {text}"
9961        );
9962        assert_eq!(
9963            bridge.requested_handle(),
9964            Some(Some("secondary".to_string())),
9965            "omitted label must resolve to the VISIBLE secondary, never hidden main"
9966        );
9967    }
9968
9969    // Omitted label with a visible main present must still prefer "main".
9970    #[tokio::test]
9971    async fn omitted_label_prefers_visible_main() {
9972        let bridge = Arc::new(ConfigBridge::new(vec![
9973            window("main", true),
9974            window("secondary", true),
9975        ]));
9976        let h = handler_with(bridge.clone());
9977        let _ = h
9978            .screenshot(Parameters(ScreenshotParams { window_label: None }))
9979            .await;
9980        assert_eq!(
9981            bridge.requested_handle(),
9982            Some(Some("main".to_string())),
9983            "with a visible main present, omitted label must resolve to main"
9984        );
9985    }
9986
9987    // Every window hidden + omitted label: error clearly, never capture a hidden window.
9988    #[tokio::test]
9989    async fn all_hidden_omitted_label_errors() {
9990        let bridge = Arc::new(ConfigBridge::new(vec![
9991            window("main", false),
9992            window("briefing", false),
9993        ]));
9994        let h = handler_with(bridge.clone());
9995        let r = h
9996            .screenshot(Parameters(ScreenshotParams { window_label: None }))
9997            .await;
9998        assert_eq!(r.is_error, Some(true), "all-hidden must error");
9999        assert!(
10000            error_text(&r).contains("no visible window"),
10001            "error must say there is no visible window, got: {}",
10002            error_text(&r)
10003        );
10004        assert!(
10005            bridge.requested_handle().is_none(),
10006            "must NOT reach the OS-handle path when every window is hidden"
10007        );
10008    }
10009
10010    // An unknown explicit label is passed THROUGH to get_native_handle (which produces the
10011    // canonical "window not found"), not rejected as "not visible".
10012    #[tokio::test]
10013    async fn unknown_label_falls_through_to_handle_resolution() {
10014        let bridge = Arc::new(ConfigBridge::new(vec![window("main", true)]));
10015        let h = handler_with(bridge.clone());
10016        let r = h
10017            .screenshot(Parameters(ScreenshotParams {
10018                window_label: Some("ghost".to_string()),
10019            }))
10020            .await;
10021        assert!(
10022            !error_text(&r).contains("not visible"),
10023            "unknown label must not be reported as 'not visible'"
10024        );
10025        assert_eq!(
10026            bridge.requested_handle(),
10027            Some(Some("ghost".to_string())),
10028            "unknown label must be forwarded verbatim to get_native_handle"
10029        );
10030    }
10031}