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
314/// The least time an eval may spend SENDING (liveness probe + injection), however short its
315/// timeout: room for the probe's full 2 s answer window plus the injection — a probe cut short
316/// cannot tell a dead page from a busy one (see `eval_outcome`, `probe_bridge`).
317const MIN_SEND_WINDOW: std::time::Duration = std::time::Duration::from_millis(2_500);
318
319/// The least time code that reached the page gets to answer (capped by its own timeout), even
320/// when sending it ran close to the deadline (see `eval_outcome`).
321const MIN_RESULT_WINDOW: std::time::Duration = std::time::Duration::from_secs(1);
322
323/// How long the liveness probe waits for the page to answer once it was dispatched (see
324/// `probe_bridge`).
325const PROBE_ANSWER_WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
326
327/// Extra eval time a page-side `wait_for` gets beyond its own `timeout_ms`, for sending the code
328/// (see `wait_for`): the probe's start wait (≤ 10 s) + its answer window (2 s) + the
329/// injection's own start wait (≤ 10 s), plus 2 s margin.
330const WAIT_FOR_SEND_SLACK_MS: u64 = 24_000;
331/// An abandoned `trace` auto-stops after this long (it captures a window every interval).
332const MAX_TRACE_DURATION: std::time::Duration = std::time::Duration::from_secs(30 * 60);
333/// Cap on the base64 bytes returned by one `trace frames` call.
334const MAX_TRACE_FRAMES_RESPONSE_BYTES: usize = 25 * 1024 * 1024;
335
336const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
337const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
338const RESOURCE_URI_STATE: &str = "victauri://state";
339
340/// SEP-2549 freshness hint for `tools/list` / `resources/list` results (5 minutes).
341/// Both lists are fixed for the process lifetime, so clients may cache them; a
342/// `notifications/tools/list_changed` (emitted by the CLI bridge on backend swap)
343/// still invalidates earlier. Conservative rather than "forever" so a client that
344/// only honors TTLs re-syncs within minutes of an app rebuild on the same port.
345const LIST_RESULT_TTL_MS: u64 = 300_000;
346
347/// Map an MCP resource URI to the tool call it mirrors: `(bare tool, capability)`.
348/// Resources are served outside the tool dispatcher, so `read_resource`/`subscribe` apply
349/// the same gate as a call of that tool action (audit B1) — including a disable of the
350/// whole tool by its bare name (R4-NET3). Returns `None` for an unknown URI (handled as
351/// not-found downstream).
352fn resource_required_capability(uri: &str) -> Option<(&'static str, &'static str)> {
353    match uri {
354        // Reading the IPC log via a resource == the `logs ipc` tool action.
355        RESOURCE_URI_IPC_LOG => Some(("logs", "logs.ipc")),
356        // Window states == the `window list` action.
357        RESOURCE_URI_WINDOWS => Some(("window", "window.list")),
358        // The state summary == reading plugin info.
359        RESOURCE_URI_STATE => Some(("get_plugin_info", "get_plugin_info")),
360        _ => None,
361    }
362}
363
364/// Whether the privacy configuration permits reading (or subscribing to) resource `uri`:
365/// exactly when it permits the tool call the resource mirrors. An unknown URI is not gated
366/// here (it is reported as not found).
367fn resource_allowed(privacy: &crate::privacy::PrivacyConfig, uri: &str) -> bool {
368    resource_required_capability(uri)
369        .is_none_or(|(tool, capability)| privacy.is_call_allowed(tool, capability))
370}
371
372const BRIDGE_VERSION: &str = env!("CARGO_PKG_VERSION");
373
374const SAFE_ENV_PREFIXES: &[&str] = &[
375    "HOME",
376    "USER",
377    "LANG",
378    "LC_",
379    "TERM",
380    "SHELL",
381    "DISPLAY",
382    "XDG_",
383    // Only Tauri's build-env namespace, NOT all of TAURI_ — the latter is an
384    // app-custom namespace that can hold secrets (audit #5).
385    "TAURI_ENV_",
386    "VICTAURI_",
387    "NODE_ENV",
388    "OS",
389    "HOSTNAME",
390    "PWD",
391    "SHLVL",
392    "LOGNAME",
393];
394
395/// Substrings that mark an env var as a secret. Even when a name matches a
396/// `SAFE_ENV_PREFIXES` entry it is dropped if it contains one of these — a prefix
397/// like `TAURI_`/`VICTAURI_` otherwise leaks `TAURI_SIGNING_PRIVATE_KEY`,
398/// `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, or `VICTAURI_AUTH_TOKEN` (audit #5).
399const SECRET_ENV_SUBSTRINGS: &[&str] = &[
400    "TOKEN",
401    "SECRET",
402    "PASS", // PASSWORD, PASSWD, PASSPHRASE
403    "PRIVATE",
404    "CREDENTIAL",
405    "APIKEY",
406    "AUTH",
407    "_KEY",
408    "DSN", // connection strings with embedded creds
409    "PAT", // personal access token
410    "JWT",
411    "BEARER",
412    "SESSION",
413    "COOKIE",
414    "SALT",
415    "CERT",
416    "SIGN", // signing keys/material
417    "LICENSE",
418];
419
420/// Whether an env var name is safe to surface via `app_info`: it must match a
421/// known-safe prefix AND not look like a secret (audit #5).
422fn is_safe_env_key(key: &str) -> bool {
423    let upper = key.to_uppercase();
424    SAFE_ENV_PREFIXES
425        .iter()
426        .any(|prefix| upper.starts_with(prefix))
427        && !SECRET_ENV_SUBSTRINGS.iter().any(|s| upper.contains(s))
428}
429
430/// MCP tool handler that dispatches tool calls to the webview bridge and state.
431#[derive(Clone)]
432pub struct VictauriMcpHandler {
433    state: Arc<VictauriState>,
434    bridge: Arc<dyn WebviewBridge>,
435    subscriptions: Arc<Mutex<HashSet<String>>>,
436    bridge_checked: Arc<AtomicBool>,
437    /// Window keys whose previous eval timed out. Retained only to annotate the
438    /// error on the *next* eval (the bridge is probed before every eval anyway).
439    timed_out_labels: Arc<Mutex<HashSet<String>>>,
440    /// Slots for running app probes ([`MAX_CONCURRENT_PROBES`]); held by the probe's thread.
441    probe_slots: Arc<tokio::sync::Semaphore>,
442    /// Slots for `read_app_file` reads ([`MAX_CONCURRENT_FILE_READS`]); held by the read's thread.
443    file_slots: Arc<tokio::sync::Semaphore>,
444    /// Deadline for one app probe ([`PROBE_TIMEOUT`]; tests of the deadline shorten it).
445    probe_timeout: std::time::Duration,
446    /// Deadline for one `read_app_file` read ([`READ_APP_FILE_TIMEOUT`]; likewise).
447    file_read_timeout: std::time::Duration,
448}
449
450#[tool_router]
451impl VictauriMcpHandler {
452    // ── Standalone Tools ────────────────────────────────────────────────────
453
454    #[tool(
455        description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.",
456        annotations(
457            read_only_hint = false,
458            destructive_hint = true,
459            idempotent_hint = false,
460            open_world_hint = false
461        )
462    )]
463    async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
464        if !self.state.privacy.is_tool_enabled("eval_js") {
465            return tool_disabled("eval_js");
466        }
467        if params.code.len() > MAX_EVAL_CODE_LEN {
468            return tool_error("code exceeds maximum length (1 MB)");
469        }
470        match self
471            .eval_with_return(&params.code, params.webview_label.as_deref())
472            .await
473        {
474            Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
475            Err(e) => tool_error(e),
476        }
477    }
478
479    #[tool(
480        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).",
481        annotations(
482            read_only_hint = true,
483            destructive_hint = false,
484            idempotent_hint = true,
485            open_world_hint = false
486        )
487    )]
488    async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
489        let format = params.format.unwrap_or(SnapshotFormat::Compact);
490        let format_str = match format {
491            SnapshotFormat::Compact => "compact",
492            SnapshotFormat::Json => "json",
493        };
494        let code = format!(
495            "return window.__VICTAURI__?.snapshot({})",
496            js_string(format_str)
497        );
498        self.eval_bridge(&code, params.webview_label.as_deref())
499            .await
500    }
501
502    #[tool(
503        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.",
504        annotations(
505            read_only_hint = true,
506            destructive_hint = false,
507            idempotent_hint = true,
508            open_world_hint = false
509        )
510    )]
511    async fn find_elements(
512        &self,
513        Parameters(params): Parameters<FindElementsParams>,
514    ) -> CallToolResult {
515        let mut parts: Vec<String> = Vec::new();
516        if let Some(t) = &params.text {
517            parts.push(format!("text: {}", js_string(t)));
518        }
519        if let Some(r) = &params.role {
520            parts.push(format!("role: {}", js_string(r)));
521        }
522        if let Some(tid) = &params.test_id {
523            parts.push(format!("test_id: {}", js_string(tid)));
524        }
525        if let Some(c) = params.css.as_ref().or(params.selector.as_ref()) {
526            parts.push(format!("css: {}", js_string(c)));
527        }
528        if let Some(n) = &params.name {
529            parts.push(format!("name: {}", js_string(n)));
530        }
531        if let Some(max) = params.max_results {
532            parts.push(format!("max_results: {max}"));
533        }
534        if let Some(t) = &params.tag {
535            parts.push(format!("tag: {}", js_string(t)));
536        }
537        if let Some(p) = &params.placeholder {
538            parts.push(format!("placeholder: {}", js_string(p)));
539        }
540        if let Some(a) = &params.alt {
541            parts.push(format!("alt: {}", js_string(a)));
542        }
543        if let Some(ta) = &params.title_attr {
544            parts.push(format!("title_attr: {}", js_string(ta)));
545        }
546        if let Some(l) = &params.label {
547            parts.push(format!("label: {}", js_string(l)));
548        }
549        if let Some(true) = params.exact {
550            parts.push("exact: true".to_string());
551        }
552        if let Some(e) = params.enabled {
553            parts.push(format!("enabled: {e}"));
554        }
555        let code = format!(
556            "return window.__VICTAURI__?.findElements({{ {} }})",
557            parts.join(", ")
558        );
559        match self
560            .eval_with_return(&code, params.webview_label.as_deref())
561            .await
562        {
563            Ok(result) => {
564                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
565                    && let Some(err) = parsed.get("error").and_then(|e| e.as_str())
566                {
567                    return tool_error(err);
568                }
569                CallToolResult::success(vec![ContentBlock::text(result)])
570            }
571            Err(e) => tool_error(e),
572        }
573    }
574
575    #[tool(
576        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.",
577        annotations(
578            read_only_hint = false,
579            destructive_hint = true,
580            idempotent_hint = false,
581            open_world_hint = false
582        )
583    )]
584    async fn invoke_command(
585        &self,
586        Parameters(params): Parameters<InvokeCommandParams>,
587    ) -> CallToolResult {
588        if !self.state.privacy.is_invoke_allowed(&params.command) {
589            return tool_disabled("invoke_command");
590        }
591        if !self.state.privacy.is_command_allowed(&params.command) {
592            return tool_error(format!(
593                "command '{}' is blocked by privacy configuration",
594                params.command
595            ));
596        }
597
598        // ── Fault injection check ──
599        if let Some(fault) = self.state.fault_registry.check_and_trigger(&params.command) {
600            match fault {
601                crate::introspection::FaultType::Delay { delay_ms } => {
602                    tracing::info!(
603                        command = %params.command,
604                        delay_ms = delay_ms,
605                        "fault injection: delaying command"
606                    );
607                    tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
608                    // After delay, continue with normal execution below
609                }
610                crate::introspection::FaultType::Error { ref message } => {
611                    tracing::info!(
612                        command = %params.command,
613                        "fault injection: returning error"
614                    );
615                    return tool_error(format!(
616                        "[FAULT INJECTED] command '{}': {message}",
617                        params.command
618                    ));
619                }
620                crate::introspection::FaultType::Drop => {
621                    tracing::info!(
622                        command = %params.command,
623                        "fault injection: dropping response"
624                    );
625                    return CallToolResult::success(vec![ContentBlock::text("{}")]);
626                }
627                crate::introspection::FaultType::Corrupt => {
628                    tracing::info!(
629                        command = %params.command,
630                        "fault injection: corrupting response"
631                    );
632                    // Execute normally but mangle the response
633                    let args_json = params.args.unwrap_or(serde_json::json!({}));
634                    let args_str =
635                        serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
636                    let code = format!(
637                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
638                        js_string(&params.command)
639                    );
640                    if let Ok(result) = self
641                        .eval_with_return(&code, params.webview_label.as_deref())
642                        .await
643                    {
644                        let corrupted = format!(
645                            "{{\"__corrupted\":true,\"original_length\":{},\"fault\":\"corrupt\"}}",
646                            result.len()
647                        );
648                        return CallToolResult::success(vec![ContentBlock::text(corrupted)]);
649                    }
650                    return CallToolResult::success(vec![ContentBlock::text(
651                        "{\"__corrupted\":true,\"fault\":\"corrupt\",\"note\":\"original invocation also failed\"}",
652                    )]);
653                }
654            }
655        }
656
657        // ── Normal execution with timing ──
658        let start = std::time::Instant::now();
659        let args_json = params.args.unwrap_or(serde_json::json!({}));
660        let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
661        let code = format!(
662            "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
663            js_string(&params.command)
664        );
665        let timeout = params.timeout_ms.map_or(self.state.eval_timeout, |ms| {
666            std::time::Duration::from_millis(ms.clamp(1, MAX_INVOKE_TIMEOUT_MS))
667        });
668        let result = self
669            .eval_outcome(&code, params.webview_label.as_deref(), timeout)
670            .await;
671        let elapsed = start.elapsed();
672        // Only calls that reached the command are timings: one never sent (saturated, dead
673        // bridge, failed injection) measures nothing, and one cut off (timeout, app exit, closed
674        // window, reload) measures how long we waited — both skewed p95.
675        let ran = result
676            .as_ref()
677            .map_or_else(|f| f.kind == EvalFailureKind::Page, |_| true);
678        if ran {
679            self.state.command_timings.record(&params.command, elapsed);
680        }
681        match result {
682            Ok(result) => {
683                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
684                    && let Some(err) = parsed.get("__error").and_then(|e| e.as_str())
685                {
686                    return tool_error(format!(
687                        "command '{}' returned error: {err}",
688                        params.command
689                    ));
690                }
691                CallToolResult::success(vec![ContentBlock::text(result)])
692            }
693            // Only a call that was SENT and then ran out of time can be a slow command; one that
694            // never left (a busy UI thread, a dead page) is not helped by a longer timeout hint.
695            Err(f)
696                if f.kind == EvalFailureKind::Aborted
697                    && f.message.starts_with("eval timed out") =>
698            {
699                tool_error(format!(
700                    "invoke_command failed: {} If the command is legitimately slow, pass \
701                     `timeout_ms` (up to {MAX_INVOKE_TIMEOUT_MS}).",
702                    f.message
703                ))
704            }
705            Err(f) => tool_error(format!("invoke_command failed: {}", f.message)),
706        }
707    }
708
709    #[tool(
710        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.",
711        annotations(
712            read_only_hint = true,
713            destructive_hint = false,
714            idempotent_hint = true,
715            open_world_hint = false
716        )
717    )]
718    async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
719        if !self.state.privacy.is_tool_enabled("screenshot") {
720            return tool_disabled("screenshot");
721        }
722        // Resolve the EXACT visible window BEFORE touching the OS handle — a silent
723        // wrong-window image is worse than a clear failure (see the helper for the history).
724        let target_label = match self.resolve_visible_capture_target(params.window_label.as_deref())
725        {
726            Ok(label) => label,
727            Err(e) => return tool_error(e),
728        };
729        match self.bridge.get_native_handle(Some(&target_label)) {
730            Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
731                Ok(png_bytes) => {
732                    use base64::Engine;
733                    let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
734                    CallToolResult::success(vec![ContentBlock::image(b64, "image/png")])
735                }
736                Err(e) => tool_error(format!("screenshot capture failed: {e}")),
737            },
738            Err(e) => tool_error(format!("cannot get window handle: {e}")),
739        }
740    }
741
742    #[tool(
743        description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.",
744        annotations(
745            read_only_hint = false,
746            destructive_hint = true,
747            idempotent_hint = false,
748            open_world_hint = false
749        )
750    )]
751    async fn verify_state(
752        &self,
753        Parameters(params): Parameters<VerifyStateParams>,
754    ) -> CallToolResult {
755        if !self.state.privacy.is_tool_enabled("eval_js") {
756            return tool_disabled("verify_state requires eval_js capability");
757        }
758        let code = expression_eval_code(&params.frontend_expr);
759        let frontend_json = match self
760            .eval_with_return(&code, params.webview_label.as_deref())
761            .await
762        {
763            Ok(result) => result,
764            Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
765        };
766
767        let frontend_state: serde_json::Value = match parse_expression_value(&frontend_json) {
768            Ok(v) => v,
769            Err(e) => {
770                return tool_error(format!(
771                    "frontend expression did not return valid JSON: {e}"
772                ));
773            }
774        };
775
776        let backend_state = if let Some(state) = params.backend_state {
777            state
778        } else if let Some(ref cmd) = params.backend_command {
779            // Gate on BOTH is_invoke_allowed and is_command_allowed, matching
780            // invoke_command and the contract/replay paths — backend_command
781            // previously checked only the blocklist (audit #30 follow-up).
782            if !self.state.privacy.is_invoke_allowed(cmd)
783                || !self.state.privacy.is_command_allowed(cmd)
784            {
785                return tool_error(format!(
786                    "command '{cmd}' is blocked by privacy configuration"
787                ));
788            }
789            let args = params.backend_args.unwrap_or(serde_json::json!({}));
790            let args_str = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
791            let invoke_code = format!(
792                "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
793                js_string(cmd)
794            );
795            match self
796                .eval_with_return(&invoke_code, params.webview_label.as_deref())
797                .await
798            {
799                Ok(result) => match serde_json::from_str(&result) {
800                    Ok(v) => v,
801                    Err(e) => {
802                        return tool_error(format!(
803                            "backend command '{cmd}' did not return valid JSON: {e}"
804                        ));
805                    }
806                },
807                Err(e) => {
808                    return tool_error(format!("failed to invoke backend command '{cmd}': {e}"));
809                }
810            }
811        } else {
812            return tool_error("either backend_state or backend_command must be provided");
813        };
814
815        let result = victauri_core::verify_state(frontend_state, backend_state);
816        json_result(&result)
817    }
818
819    #[tool(
820        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).",
821        annotations(
822            read_only_hint = true,
823            destructive_hint = false,
824            idempotent_hint = true,
825            open_world_hint = false
826        )
827    )]
828    async fn detect_ghost_commands(
829        &self,
830        Parameters(params): Parameters<GhostCommandParams>,
831    ) -> CallToolResult {
832        // Project a per-command OUTCOME summary in JS ({command, ok, err}, deduped). Ghost
833        // detection is outcome-based (VIC-1): a command that returned success provably has a
834        // handler and is never a ghost; one that errored "not found" is a confirmed ghost.
835        // Aggregating per command keeps this tiny even on a busy app (avoids the eval cap).
836        // When `since_ms` is set, the projection time-windows to the current test's traffic.
837        let code = ghost_ipc_outcomes_js(params.since_ms);
838        let ipc_json = match self
839            .eval_with_return(&code, params.webview_label.as_deref())
840            .await
841        {
842            Ok(r) => r,
843            Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
844        };
845
846        let outcomes: Vec<crate::mcp::helpers::IpcOutcome> = match serde_json::from_str(&ipc_json) {
847            Ok(v) => v,
848            Err(e) => return tool_error(format!("failed to parse IPC log JSON: {e}")),
849        };
850
851        let mut report = build_ghost_report(&outcomes, &self.state.registry);
852        if let Some(obj) = report.as_object_mut() {
853            obj.insert(
854                "ipc_capture".to_string(),
855                self.ipc_capture_status(params.webview_label.as_deref())
856                    .await,
857            );
858        }
859        json_result(&report)
860    }
861
862    #[tool(
863        description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.",
864        annotations(
865            read_only_hint = true,
866            destructive_hint = false,
867            idempotent_hint = true,
868            open_world_hint = false
869        )
870    )]
871    async fn check_ipc_integrity(
872        &self,
873        Parameters(params): Parameters<IpcIntegrityParams>,
874    ) -> CallToolResult {
875        let threshold = params.stale_threshold_ms.unwrap_or(5000);
876        let code = ipc_integrity_js(threshold);
877        self.eval_bridge(&code, params.webview_label.as_deref())
878            .await
879    }
880
881    #[tool(
882        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.",
883        annotations(
884            read_only_hint = false,
885            destructive_hint = true,
886            idempotent_hint = false,
887            open_world_hint = false
888        )
889    )]
890    async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
891        let timeout_ms = params.timeout_ms.unwrap_or(10_000).min(120_000);
892        // Clamp BEFORE the value reaches JS: a poll longer than the wait is meaningless, and
893        // one past 2^31 ms overflows `setTimeout` (which then fires immediately, in a loop).
894        let poll = params.poll_ms.unwrap_or(200).clamp(20, timeout_ms.max(20));
895
896        // The `expression` and `event` conditions are awaited server-side (they
897        // poll the eval engine and the captured event bus respectively), so a
898        // fire-and-forget backend command can be awaited to true completion.
899        match params.condition {
900            WaitCondition::Expression => {
901                return self.wait_for_expression(&params, timeout_ms, poll).await;
902            }
903            WaitCondition::Event => {
904                return self.wait_for_event(&params, timeout_ms, poll).await;
905            }
906            _ => {}
907        }
908
909        let value = params
910            .value
911            .as_ref()
912            .map_or_else(|| "null".to_string(), |v| js_string(v));
913        let code = format!(
914            "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
915            js_string(params.condition.as_str())
916        );
917        // `waitFor` starts its own `timeout_ms` clock only once the code runs, while the eval's
918        // timeout covers the whole call — so the slack must also cover SENDING the code on a busy
919        // UI thread (the probe AND the injection are main-thread round trips that may each wait
920        // up to 10 s to start, plus the probe's 2 s answer window), or the condition's own
921        // answer would be cut off as an eval timeout.
922        let eval_timeout = std::time::Duration::from_millis(timeout_ms + WAIT_FOR_SEND_SLACK_MS);
923        match self
924            .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
925            .await
926        {
927            Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
928            Err(e) => tool_error(e),
929        }
930    }
931
932    /// Poll a JS expression until truthy (or `== expected`), server-side.
933    ///
934    /// Level-triggered and race-free: each poll re-evaluates the expression via
935    /// the same engine as `eval_js`, so it may `await`. Eval errors are treated
936    /// as "not yet met" (the target may not exist during startup) and the last
937    /// error is surfaced on timeout.
938    async fn wait_for_expression(
939        &self,
940        params: &WaitForParams,
941        timeout_ms: u64,
942        poll_ms: u64,
943    ) -> CallToolResult {
944        if !self.state.privacy.is_tool_enabled("eval_js") {
945            return tool_disabled("wait_for(expression) requires eval_js capability");
946        }
947        let Some(expr) = params.value.as_deref().filter(|s| !s.is_empty()) else {
948            return missing_param("value", "wait_for(expression)");
949        };
950        let code = expression_eval_code(expr);
951        let start = std::time::Instant::now();
952        let deadline = start + std::time::Duration::from_millis(timeout_ms);
953        let poll = std::time::Duration::from_millis(poll_ms);
954        let mut last_value = serde_json::Value::Null;
955        let mut last_error: Option<String> = None;
956
957        loop {
958            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
959            let per_eval = remaining
960                .min(std::time::Duration::from_secs(15))
961                .max(std::time::Duration::from_secs(1));
962            match self
963                .eval_outcome(&code, params.webview_label.as_deref(), per_eval)
964                .await
965            {
966                // Code that does not parse can never start matching: fail now instead of
967                // polling to the timeout.
968                Err(f) if f.is_parse_error() => {
969                    return tool_error(format!("wait_for(expression): {}", f.message));
970                }
971                Ok(raw) => {
972                    let val = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null);
973                    let met = match &params.expected {
974                        Some(expected) => &val == expected,
975                        None => json_truthy(&val),
976                    };
977                    if met {
978                        return json_result(&serde_json::json!({
979                            "ok": true,
980                            "value": val,
981                            "elapsed_ms": start.elapsed().as_millis() as u64,
982                        }));
983                    }
984                    last_value = val;
985                }
986                Err(e) => last_error = Some(e.message),
987            }
988
989            if std::time::Instant::now() >= deadline {
990                return json_result(&serde_json::json!({
991                    "ok": false,
992                    "error": format!("timeout after {timeout_ms}ms"),
993                    "last_value": last_value,
994                    "last_error": last_error,
995                    "elapsed_ms": start.elapsed().as_millis() as u64,
996                }));
997            }
998            tokio::time::sleep(
999                poll.min(deadline.saturating_duration_since(std::time::Instant::now())),
1000            )
1001            .await;
1002        }
1003    }
1004
1005    /// Block until a named Tauri event appears on the captured event bus.
1006    ///
1007    /// Edge-triggered: matches the most recent event whose timestamp is no older
1008    /// than `since_ms` before this call began, so an event fired in the gap
1009    /// between `invoke_command` and this call is still caught. Polls the
1010    /// event-bus ring buffer — no webview eval involved.
1011    async fn wait_for_event(
1012        &self,
1013        params: &WaitForParams,
1014        timeout_ms: u64,
1015        poll_ms: u64,
1016    ) -> CallToolResult {
1017        let Some(name) = params.value.as_deref().filter(|s| !s.is_empty()) else {
1018            return missing_param("value", "wait_for(event)");
1019        };
1020        let since_ms = params.since_ms.unwrap_or(2000);
1021        let start = std::time::Instant::now();
1022        let baseline = bounded::ms_ago(chrono::Utc::now(), since_ms);
1023        let deadline = start + std::time::Duration::from_millis(timeout_ms);
1024        let poll = std::time::Duration::from_millis(poll_ms);
1025
1026        loop {
1027            // Search newest-first for a matching event no older than the baseline.
1028            let matched = self.state.event_bus.events().into_iter().rev().find(|e| {
1029                e.name == name
1030                    && chrono::DateTime::parse_from_rfc3339(&e.timestamp)
1031                        .map_or(true, |ts| ts.with_timezone(&chrono::Utc) >= baseline)
1032            });
1033            if let Some(ev) = matched {
1034                return json_result(&serde_json::json!({
1035                    "ok": true,
1036                    "event": {
1037                        "name": ev.name,
1038                        "payload": ev.payload,
1039                        "timestamp": ev.timestamp,
1040                    },
1041                    "elapsed_ms": start.elapsed().as_millis() as u64,
1042                }));
1043            }
1044            if std::time::Instant::now() >= deadline {
1045                return json_result(&serde_json::json!({
1046                    "ok": false,
1047                    "error": format!("timeout after {timeout_ms}ms waiting for event '{name}'"),
1048                    "hint": "Ensure the app emits this Tauri event and Victauri captures it: \
1049                             custom events need VictauriBuilder::listen_events(&[\"…\"]); \
1050                             window-lifecycle events are captured automatically.",
1051                    "elapsed_ms": start.elapsed().as_millis() as u64,
1052                }));
1053            }
1054            // Never sleep past the deadline (a full poll used to overshoot a short timeout).
1055            tokio::time::sleep(
1056                poll.min(deadline.saturating_duration_since(std::time::Instant::now())),
1057            )
1058            .await;
1059        }
1060    }
1061
1062    #[tool(
1063        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.",
1064        annotations(
1065            read_only_hint = false,
1066            destructive_hint = true,
1067            idempotent_hint = false,
1068            open_world_hint = false
1069        )
1070    )]
1071    async fn assert_semantic(
1072        &self,
1073        Parameters(params): Parameters<SemanticAssertParams>,
1074    ) -> CallToolResult {
1075        if !self.state.privacy.is_tool_enabled("eval_js") {
1076            return tool_disabled("assert_semantic requires eval_js capability");
1077        }
1078        let code = expression_eval_code(&params.expression);
1079        let actual_json = match self
1080            .eval_with_return(&code, params.webview_label.as_deref())
1081            .await
1082        {
1083            Ok(result) => result,
1084            Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
1085        };
1086
1087        let actual: serde_json::Value = match parse_expression_value(&actual_json) {
1088            Ok(v) => v,
1089            Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
1090        };
1091
1092        let assertion =
1093            victauri_core::SemanticAssertion::new(params.label, params.condition, params.expected);
1094
1095        let result = victauri_core::evaluate_assertion(actual, &assertion);
1096        json_result(&result)
1097    }
1098
1099    #[tool(
1100        description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.",
1101        annotations(
1102            read_only_hint = true,
1103            destructive_hint = false,
1104            idempotent_hint = true,
1105            open_world_hint = false
1106        )
1107    )]
1108    async fn resolve_command(
1109        &self,
1110        Parameters(params): Parameters<ResolveCommandParams>,
1111    ) -> CallToolResult {
1112        let limit = params.limit.unwrap_or(5);
1113        let mut results = self.state.registry.resolve(&params.query);
1114        results.truncate(limit);
1115        json_result(&results)
1116    }
1117
1118    #[tool(
1119        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.",
1120        annotations(
1121            read_only_hint = true,
1122            destructive_hint = false,
1123            idempotent_hint = true,
1124            open_world_hint = false
1125        )
1126    )]
1127    async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
1128        let commands = match params.query {
1129            Some(q) => self.state.registry.search(&q),
1130            None => self.state.registry.list(),
1131        };
1132        json_result(&commands)
1133    }
1134
1135    #[tool(
1136        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).",
1137        annotations(
1138            read_only_hint = true,
1139            destructive_hint = false,
1140            idempotent_hint = true,
1141            open_world_hint = false
1142        )
1143    )]
1144    async fn app_state(&self, Parameters(params): Parameters<AppStateParams>) -> CallToolResult {
1145        let Some(name) = params.probe else {
1146            return json_result(&serde_json::json!({ "probes": self.state.probes.names() }));
1147        };
1148        if let Some(probe) = self.state.probes.get(&name) {
1149            // A probe is app code: run it off the async executor, bounded, and with a panic
1150            // boundary (it used to run inline on a tokio worker with neither). Its slot is
1151            // held by the probe's thread, so a hung probe that outlives the deadline still
1152            // counts against the cap and repeated calls cannot pile up leaked threads.
1153            let Ok(slot) = Arc::clone(&self.probe_slots).try_acquire_owned() else {
1154                return tool_error(format!(
1155                    "probe '{name}' not run: app probes are busy ({MAX_CONCURRENT_PROBES} \
1156                     still running — a probe that hangs keeps running past its timeout). \
1157                     Retry shortly."
1158                ));
1159            };
1160            match bounded::run_blocking_bounded(
1161                None,
1162                &format!("probe '{name}'"),
1163                self.probe_timeout,
1164                move || {
1165                    let _slot = slot;
1166                    Ok(probe())
1167                },
1168            )
1169            .await
1170            {
1171                Ok(value) => json_result(&value),
1172                Err(e) => tool_error(e),
1173            }
1174        } else {
1175            let available = self.state.probes.names();
1176            tool_error_with_hint(
1177                format!(
1178                    "unknown probe '{name}'. Available probes: {}",
1179                    if available.is_empty() {
1180                        "(none registered — add VictauriBuilder::probe(\"name\", ...))".to_string()
1181                    } else {
1182                        available.join(", ")
1183                    }
1184                ),
1185                RecoveryHint::CheckInput,
1186            )
1187        }
1188    }
1189
1190    #[tool(
1191        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.",
1192        annotations(
1193            read_only_hint = true,
1194            destructive_hint = false,
1195            idempotent_hint = true,
1196            open_world_hint = false
1197        )
1198    )]
1199    async fn get_memory_stats(&self) -> CallToolResult {
1200        let stats = crate::memory::current_stats();
1201        json_result(&stats)
1202    }
1203
1204    #[tool(
1205        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.",
1206        annotations(
1207            read_only_hint = true,
1208            destructive_hint = false,
1209            idempotent_hint = true,
1210            open_world_hint = false
1211        )
1212    )]
1213    async fn get_plugin_info(&self) -> CallToolResult {
1214        let disabled: Vec<&str> = self
1215            .state
1216            .privacy
1217            .disabled_tools
1218            .iter()
1219            .map(std::string::String::as_str)
1220            .collect();
1221        let blocklist: Vec<&str> = self
1222            .state
1223            .privacy
1224            .command_blocklist
1225            .iter()
1226            .map(std::string::String::as_str)
1227            .collect();
1228        let allowlist: Option<Vec<&str>> = self
1229            .state
1230            .privacy
1231            .command_allowlist
1232            .as_ref()
1233            .map(|s| s.iter().map(std::string::String::as_str).collect());
1234        let all_tools = Self::tool_router().list_all();
1235        let enabled_tools: Vec<&str> = all_tools
1236            .iter()
1237            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
1238            .map(|t| t.name.as_ref())
1239            .collect();
1240
1241        // Host-app identity: lets an agent verify on its FIRST call that it reached the
1242        // intended app (not another Victauri instance sharing the discovery port).
1243        let app_cfg = self.bridge.tauri_config();
1244        let result = serde_json::json!({
1245            "version": env!("CARGO_PKG_VERSION"),
1246            "bridge_version": BRIDGE_VERSION,
1247            "port": self.state.port.load(Ordering::Relaxed),
1248            "app": {
1249                "identifier": app_cfg.get("identifier"),
1250                "product_name": app_cfg.get("product_name"),
1251            },
1252            "tools": {
1253                "total": all_tools.len(),
1254                "enabled": enabled_tools.len(),
1255                "enabled_list": enabled_tools,
1256                "disabled_list": disabled,
1257            },
1258            "commands": {
1259                "allowlist": allowlist,
1260                "blocklist": blocklist,
1261            },
1262            "privacy": {
1263                "profile": self.state.privacy.profile.to_string(),
1264                "redaction_enabled": self.state.privacy.redaction_enabled,
1265            },
1266            "capacities": {
1267                "event_log": self.state.event_log.capacity(),
1268                "eval_timeout_secs": self.state.eval_timeout.as_secs(),
1269            },
1270            "registered_commands": self.state.registry.count(),
1271            "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
1272            "uptime_secs": self.state.started_at.elapsed().as_secs(),
1273        });
1274        json_result(&result)
1275    }
1276
1277    #[tool(
1278        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.",
1279        annotations(
1280            read_only_hint = true,
1281            destructive_hint = false,
1282            idempotent_hint = true,
1283            open_world_hint = false
1284        )
1285    )]
1286    async fn get_diagnostics(
1287        &self,
1288        Parameters(params): Parameters<DiagnosticsParams>,
1289    ) -> CallToolResult {
1290        self.eval_bridge(
1291            "return window.__VICTAURI__?.getDiagnostics()",
1292            params.webview_label.as_deref(),
1293        )
1294        .await
1295    }
1296
1297    // ── Backend Access Tools ───────────────────────────────────────────────
1298
1299    #[tool(
1300        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.",
1301        annotations(
1302            read_only_hint = true,
1303            destructive_hint = false,
1304            idempotent_hint = true,
1305            open_world_hint = false
1306        )
1307    )]
1308    async fn app_info(&self) -> CallToolResult {
1309        let config = self.bridge.tauri_config();
1310
1311        let data_dir = self.bridge.app_data_dir().ok();
1312        let config_dir = self.bridge.app_config_dir().ok();
1313        let log_dir = self.bridge.app_log_dir().ok();
1314        let local_data_dir = self.bridge.app_local_data_dir().ok();
1315
1316        let env_vars: std::collections::BTreeMap<String, String> = std::env::vars()
1317            .filter(|(k, _)| is_safe_env_key(k))
1318            .collect();
1319
1320        // Enumerate every database candidate across ALL roots (configured db_search_paths
1321        // + every OS app dir), each tagged with size, whether it is a WebView/engine
1322        // internal store, and whether it is the one `query_db` would auto-select. This lets
1323        // an agent see and disambiguate the real app DB instead of guessing (audit /
1324        // red-team "wrong DB" finding — `app_info.databases` previously only walked
1325        // data_dir and returned bare relative names).
1326        #[cfg(feature = "sqlite")]
1327        let databases: Vec<serde_json::Value> = {
1328            let mut all_dirs: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
1329            for d in [
1330                data_dir.as_ref(),
1331                config_dir.as_ref(),
1332                log_dir.as_ref(),
1333                local_data_dir.as_ref(),
1334            ]
1335            .into_iter()
1336            .flatten()
1337            {
1338                all_dirs.push(d.clone());
1339            }
1340            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
1341                all_dirs.clone()
1342            } else {
1343                self.state.db_search_paths.clone()
1344            };
1345            // A directory walk (a slow or network disk can take seconds): off the server's
1346            // async workers.
1347            tokio::task::spawn_blocking(move || {
1348                let selected = crate::database::select_app_database(&select_dirs).ok();
1349                crate::database::classify_databases(&all_dirs)
1350                    .into_iter()
1351                    .map(|c| {
1352                        serde_json::json!({
1353                            "path": c.path.to_string_lossy(),
1354                            "size_bytes": c.size_bytes,
1355                            "webview_internal": c.webview_internal,
1356                            "selected": selected.as_ref() == Some(&c.path),
1357                        })
1358                    })
1359                    .collect()
1360            })
1361            .await
1362            .unwrap_or_default()
1363        };
1364
1365        #[cfg(not(feature = "sqlite"))]
1366        let databases: Vec<serde_json::Value> = Vec::new();
1367
1368        let result = serde_json::json!({
1369            "config": config,
1370            "paths": {
1371                "data": data_dir.as_ref().map(|p| p.to_string_lossy()),
1372                "config": config_dir.as_ref().map(|p| p.to_string_lossy()),
1373                "log": log_dir.as_ref().map(|p| p.to_string_lossy()),
1374                "local_data": local_data_dir.as_ref().map(|p| p.to_string_lossy()),
1375            },
1376            "databases": databases,
1377            "env": env_vars,
1378            "process": {
1379                "pid": std::process::id(),
1380                "arch": std::env::consts::ARCH,
1381                "os": std::env::consts::OS,
1382                "family": std::env::consts::FAMILY,
1383            },
1384        });
1385        json_result(&result)
1386    }
1387
1388    #[tool(
1389        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.",
1390        annotations(
1391            read_only_hint = true,
1392            destructive_hint = false,
1393            idempotent_hint = true,
1394            open_world_hint = false
1395        )
1396    )]
1397    async fn list_app_dir(
1398        &self,
1399        Parameters(params): Parameters<ListAppDirParams>,
1400    ) -> CallToolResult {
1401        let base = match self.resolve_app_dir(params.directory) {
1402            Ok(d) => d,
1403            Err(e) => return tool_error(e),
1404        };
1405
1406        // Lexical traversal guard BEFORE any filesystem access: a `..` or absolute sub-path
1407        // is rejected as traversal up front.
1408        if let Some(sub) = params.path.as_deref()
1409            && let Err(e) = Self::lexical_safe(std::path::Path::new(sub))
1410        {
1411            return tool_error(e);
1412        }
1413        // Every filesystem call below is synchronous and unbounded in the directory's size, so
1414        // the whole walk runs on the blocking pool (it used to run on an async worker).
1415        let walk = tokio::task::spawn_blocking(move || Self::list_app_dir_blocking(&base, &params));
1416        match walk.await {
1417            Ok(Ok(listing)) => json_result(&listing),
1418            Ok(Err(e)) => tool_error(e),
1419            Err(e) => tool_error(format!("directory listing task failed: {e}")),
1420        }
1421    }
1422
1423    fn list_app_dir_blocking(
1424        base: &std::path::Path,
1425        params: &ListAppDirParams,
1426    ) -> Result<serde_json::Value, String> {
1427        let sub = params.path.clone().unwrap_or_default();
1428        let target = base.join(&sub);
1429        // A missing directory is a normal, non-error result — unless the path escapes the
1430        // base through a symlink, which is refused whether or not its target exists (so the
1431        // listing is no oracle for paths outside the base).
1432        if !Self::contained_or_missing(base, &target)? {
1433            return Ok(serde_json::json!({
1434                "base": base.to_string_lossy(),
1435                "path": sub,
1436                "exists": false,
1437                "entries": [],
1438                "count": 0,
1439            }));
1440        }
1441        let canon_base = std::fs::canonicalize(base)
1442            .map_err(|e| format!("cannot resolve base directory: {e}"))?;
1443
1444        let mut walk = DirWalk {
1445            canon_base,
1446            pattern: params.pattern.clone(),
1447            max_depth: params.max_depth.unwrap_or(1).min(5),
1448            deadline: std::time::Instant::now() + MAX_DIR_WALK_TIME,
1449            max_visited: MAX_DIR_VISITED,
1450            visited: 0,
1451            truncated: false,
1452            entries: Vec::new(),
1453        };
1454        walk.visit(&target, base, 0);
1455
1456        Ok(serde_json::json!({
1457            "base": base.to_string_lossy(),
1458            "path": sub,
1459            "exists": true,
1460            "count": walk.entries.len(),
1461            "entries": walk.entries,
1462            "truncated": walk.truncated,
1463        }))
1464    }
1465
1466    #[tool(
1467        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.",
1468        annotations(
1469            read_only_hint = true,
1470            destructive_hint = false,
1471            idempotent_hint = true,
1472            open_world_hint = false
1473        )
1474    )]
1475    async fn read_app_file(
1476        &self,
1477        Parameters(params): Parameters<ReadAppFileParams>,
1478    ) -> CallToolResult {
1479        let base = match self.resolve_app_dir(params.directory) {
1480            Ok(d) => d,
1481            Err(e) => return tool_error(e),
1482        };
1483
1484        // Lexical traversal guard FIRST — before the existence check — so a
1485        // traversal attempt (`..` / absolute) is rejected as traversal rather
1486        // than leaking whether the out-of-tree target exists via "file not
1487        // found". `safe_within` (which canonicalizes) stays below as
1488        // defense-in-depth for real files.
1489        if let Err(e) = Self::lexical_safe(std::path::Path::new(&params.path)) {
1490            return tool_error(e);
1491        }
1492        let target = base.join(&params.path);
1493        // Refused as traversal whether or not a symlinked-out target exists (no oracle).
1494        match Self::contained_or_missing(&base, &target) {
1495            Ok(true) => {}
1496            Ok(false) => return tool_error(format!("file not found: {}", params.path)),
1497            Err(e) => return tool_error(e),
1498        }
1499        if let Err(e) = Self::safe_within(&base, &target) {
1500            return tool_error(e);
1501        }
1502        if !target.is_file() {
1503            return tool_error(format!("not a file: {}", params.path));
1504        }
1505
1506        let max_bytes = params.max_bytes.unwrap_or(1_048_576).min(10_485_760);
1507
1508        // Open the CANONICAL validated path, and do the blocking file IO on the blocking pool.
1509        // Doing sync `std::fs` IO directly in this async fn could stall the executor thread if a
1510        // regular file were swapped (between the `safe_within` check and the open) for a FIFO or
1511        // slow device whose `read_to_end` blocks; opening the canonical path also closes the
1512        // trivial validate-lexical / open-lexical symlink-swap window.
1513        let canonical = match std::fs::canonicalize(&target) {
1514            Ok(c) => c,
1515            Err(e) => return tool_error(format!("cannot resolve path: {e}")),
1516        };
1517        let (mut bytes, original_size, modified) =
1518            match self.read_regular_file_bounded(canonical, max_bytes).await {
1519                Ok(v) => v,
1520                Err(e) => return tool_error(format!("failed to read file: {e}")),
1521            };
1522        let truncated = bytes.len() > max_bytes;
1523        if truncated {
1524            bytes.truncate(max_bytes);
1525        }
1526
1527        let file_info = serde_json::json!({
1528            "path": params.path,
1529            "size": original_size,
1530            "truncated": truncated,
1531            "modified": modified,
1532        });
1533
1534        if params.binary == Some(true) {
1535            use base64::Engine;
1536            let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1537            json_result(&serde_json::json!({
1538                "file": file_info,
1539                "encoding": "base64",
1540                "content": b64,
1541            }))
1542        } else {
1543            // Truncation can split a multi-byte character; that is not a non-UTF-8 file.
1544            // `error_len() == None` means the bytes end mid-character, so drop the partial
1545            // character instead of returning the whole read as base64.
1546            if truncated
1547                && let Err(e) = std::str::from_utf8(&bytes)
1548                && e.error_len().is_none()
1549            {
1550                bytes.truncate(e.valid_up_to());
1551            }
1552            match String::from_utf8(bytes) {
1553                Ok(text) => json_result(&serde_json::json!({
1554                    "file": file_info,
1555                    "encoding": "utf-8",
1556                    "content": text,
1557                })),
1558                Err(e) => {
1559                    use base64::Engine;
1560                    let bytes = e.into_bytes();
1561                    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1562                    json_result(&serde_json::json!({
1563                        "file": file_info,
1564                        "encoding": "base64",
1565                        "note": "file is not valid UTF-8, returning base64",
1566                        "content": b64,
1567                    }))
1568                }
1569            }
1570        }
1571    }
1572
1573    #[tool(
1574        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.",
1575        annotations(
1576            read_only_hint = true,
1577            destructive_hint = false,
1578            idempotent_hint = true,
1579            open_world_hint = false
1580        )
1581    )]
1582    async fn query_db(&self, Parameters(params): Parameters<QueryDbParams>) -> CallToolResult {
1583        // query_db is ALWAYS registered as a tool so the rmcp `#[tool_router]` macro
1584        // compiles with `default-features = false` (a consumer that drops the heavy
1585        // rusqlite C dependency). The actual SQLite implementation only exists with the
1586        // `sqlite` feature; without it, return a clear, actionable error.
1587        #[cfg(feature = "sqlite")]
1588        {
1589            self.query_db_impl(params).await
1590        }
1591        #[cfg(not(feature = "sqlite"))]
1592        {
1593            let _ = params;
1594            tool_error(
1595                "query_db is unavailable: this build was compiled without the 'sqlite' \
1596                 feature (default-features = false). Re-enable the 'sqlite' feature to use it.",
1597            )
1598        }
1599    }
1600
1601    /// Real `query_db` implementation — compiled only with the `sqlite` feature.
1602    #[cfg(feature = "sqlite")]
1603    async fn query_db_impl(&self, params: QueryDbParams) -> CallToolResult {
1604        // A refused query is refused for what it is, before any database is looked up: the
1605        // error used to depend on whether the app HAS a database ("no application database"
1606        // for a DELETE on an app without one), and it touched the filesystem for nothing.
1607        if let Err(e) = crate::database::validate_query(&params.query) {
1608            return tool_error(e);
1609        }
1610        let data_dir = match self.bridge.app_data_dir() {
1611            Ok(d) => d,
1612            Err(e) => return tool_error(format!("cannot access app data directory: {e}")),
1613        };
1614
1615        let search_dirs = self.db_roots();
1616
1617        let db_path = if let Some(ref requested_path) = params.path {
1618            match Self::resolve_existing_db_path(&search_dirs, requested_path) {
1619                Ok(path) => path,
1620                Err(e) => return tool_error(e),
1621            }
1622        } else {
1623            // Auto-select the application DB. When db_search_paths is configured it is
1624            // EXCLUSIVE — never fall back to OS app dirs (which hold WebView internals),
1625            // so a configured-but-empty root yields a clear error instead of silently
1626            // querying the wrong database. WebView/browser-engine internal stores are
1627            // excluded and the largest remaining candidate wins (audit / red-team "wrong
1628            // DB" finding).
1629            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
1630                search_dirs.clone()
1631            } else {
1632                self.state.db_search_paths.clone()
1633            };
1634            // A directory walk: off the server's async workers (see `app_info`).
1635            match tokio::task::spawn_blocking(move || {
1636                crate::database::select_app_database(&select_dirs)
1637            })
1638            .await
1639            {
1640                Ok(Ok(p)) => p,
1641                Ok(Err(e)) => return tool_error(e),
1642                Err(e) => return tool_error(format!("database discovery failed: {e}")),
1643            }
1644        };
1645
1646        let db_display = db_path
1647            .strip_prefix(&data_dir)
1648            .unwrap_or(&db_path)
1649            .to_string_lossy()
1650            .into_owned();
1651        let bind_params = params.params.unwrap_or_default();
1652        let query = params.query;
1653        let max_rows = params.max_rows;
1654
1655        match bounded::run_blocking_bounded(
1656            Some(&bounded::DB_SLOTS),
1657            "database query",
1658            crate::database::QUERY_TIMEOUT + bounded::BLOCKING_DEADLINE_SLACK,
1659            move || crate::database::query(&db_path, &query, &bind_params, max_rows),
1660        )
1661        .await
1662        {
1663            Ok(mut result) => {
1664                if let Some(obj) = result.as_object_mut() {
1665                    obj.insert("database".to_string(), serde_json::json!(db_display));
1666                }
1667                json_result(&result)
1668            }
1669            Err(e) => tool_error(e),
1670        }
1671    }
1672
1673    /// Roots a `query_db` / `db_health` database `path` resolves against, in precedence
1674    /// order: configured `db_search_paths` (so a configured app DB beats incidental ones such
1675    /// as `WebView` internals), then the app's data, config, local-data and log directories.
1676    /// De-duplicated keeping the FIRST occurrence: a relative path present under two roots
1677    /// always resolves to the same file (a `HashSet` made that nondeterministic), and both
1678    /// tools search the same places (`db_health` used to skip the log directory).
1679    #[cfg(feature = "sqlite")]
1680    fn db_roots(&self) -> Vec<std::path::PathBuf> {
1681        let mut roots: Vec<std::path::PathBuf> = Vec::new();
1682        let app_dirs = [
1683            self.bridge.app_data_dir(),
1684            self.bridge.app_config_dir(),
1685            self.bridge.app_local_data_dir(),
1686            self.bridge.app_log_dir(),
1687        ];
1688        let candidates = self
1689            .state
1690            .db_search_paths
1691            .iter()
1692            .cloned()
1693            .chain(app_dirs.into_iter().filter_map(Result::ok));
1694        for dir in candidates {
1695            if !roots.contains(&dir) {
1696                roots.push(dir);
1697            }
1698        }
1699        roots
1700    }
1701
1702    // ── Compound Tools ──────────────────────────────────────────────────────
1703
1704    #[tool(
1705        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].",
1706        annotations(
1707            read_only_hint = false,
1708            destructive_hint = false,
1709            idempotent_hint = false,
1710            open_world_hint = false
1711        )
1712    )]
1713    async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
1714        if !self.state.privacy.is_tool_enabled("interact") {
1715            return tool_disabled("interact");
1716        }
1717        match params.action {
1718            InteractAction::Click => {
1719                if !self.state.privacy.is_tool_enabled("interact.click") {
1720                    return tool_disabled("interact.click");
1721                }
1722                let Some(ref_id) = &params.ref_id else {
1723                    return missing_param("ref_id", "click");
1724                };
1725                if params.trusted.unwrap_or(false) {
1726                    // Resolve the click point in the top window's viewport (frame offsets
1727                    // added), refusing a disabled/hidden/covered/off-screen element — a real
1728                    // OS click lands on whatever is on screen there — then deliver it.
1729                    let probe = trusted_click_probe_js(ref_id);
1730                    let raw = match self
1731                        .eval_with_return(&probe, params.webview_label.as_deref())
1732                        .await
1733                    {
1734                        Ok(r) => r,
1735                        Err(e) => return tool_error(e),
1736                    };
1737                    let point = serde_json::from_str::<serde_json::Value>(&raw).unwrap_or_default();
1738                    if let Some(why) = point.get("error").and_then(serde_json::Value::as_str) {
1739                        return tool_error_with_hint(
1740                            format!(
1741                                "trusted click on {ref_id} refused: {why} — no OS click was sent"
1742                            ),
1743                            RecoveryHint::CheckInput,
1744                        );
1745                    }
1746                    let (Some(x), Some(y)) = (
1747                        point.get("x").and_then(serde_json::Value::as_f64),
1748                        point.get("y").and_then(serde_json::Value::as_f64),
1749                    ) else {
1750                        return tool_error_with_hint(
1751                            format!("ref not found: {ref_id}"),
1752                            RecoveryHint::CheckInput,
1753                        );
1754                    };
1755                    if !(x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0) {
1756                        return tool_error_with_hint(
1757                            format!(
1758                                "trusted click on {ref_id} refused: the page reported an \
1759                                 unusable click point ({x}, {y}) — no OS click was sent"
1760                            ),
1761                            RecoveryHint::CheckInput,
1762                        );
1763                    }
1764                    let bridge = self.bridge.clone();
1765                    let label = params.webview_label.clone();
1766                    let native = tokio::task::spawn_blocking(move || {
1767                        bridge.native_click(label.as_deref(), x, y)
1768                    })
1769                    .await
1770                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1771                    return match native {
1772                        Ok(()) => json_result(
1773                            &serde_json::json!({"ok": true, "trusted": true, "x": x, "y": y}),
1774                        ),
1775                        Err(e) => tool_error(e),
1776                    };
1777                }
1778                let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
1779                self.eval_page_action(&code, params.webview_label.as_deref())
1780                    .await
1781            }
1782            InteractAction::DoubleClick => {
1783                if !self.state.privacy.is_tool_enabled("interact.double_click") {
1784                    return tool_disabled("interact.double_click");
1785                }
1786                let Some(ref_id) = &params.ref_id else {
1787                    return missing_param("ref_id", "double_click");
1788                };
1789                let code = format!(
1790                    "return window.__VICTAURI__?.doubleClick({})",
1791                    js_string(ref_id)
1792                );
1793                self.eval_page_action(&code, params.webview_label.as_deref())
1794                    .await
1795            }
1796            InteractAction::Hover => {
1797                if !self.state.privacy.is_tool_enabled("interact.hover") {
1798                    return tool_disabled("interact.hover");
1799                }
1800                let Some(ref_id) = &params.ref_id else {
1801                    return missing_param("ref_id", "hover");
1802                };
1803                let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
1804                self.eval_page_action(&code, params.webview_label.as_deref())
1805                    .await
1806            }
1807            InteractAction::Focus => {
1808                if !self.state.privacy.is_tool_enabled("interact.focus") {
1809                    return tool_disabled("interact.focus");
1810                }
1811                let Some(ref_id) = &params.ref_id else {
1812                    return missing_param("ref_id", "focus");
1813                };
1814                let code = format!(
1815                    "return window.__VICTAURI__?.focusElement({})",
1816                    js_string(ref_id)
1817                );
1818                self.eval_page_action(&code, params.webview_label.as_deref())
1819                    .await
1820            }
1821            InteractAction::ScrollIntoView => {
1822                if !self
1823                    .state
1824                    .privacy
1825                    .is_tool_enabled("interact.scroll_into_view")
1826                {
1827                    return tool_disabled("interact.scroll_into_view");
1828                }
1829                let ref_arg = params
1830                    .ref_id
1831                    .as_ref()
1832                    .map_or_else(|| "null".to_string(), |r| js_string(r));
1833                let x = params.x.unwrap_or(0.0);
1834                let y = params.y.unwrap_or(0.0);
1835                let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
1836                self.eval_page_action(&code, params.webview_label.as_deref())
1837                    .await
1838            }
1839            InteractAction::SelectOption => {
1840                if !self.state.privacy.is_tool_enabled("interact.select_option") {
1841                    return tool_disabled("interact.select_option");
1842                }
1843                let Some(ref_id) = &params.ref_id else {
1844                    return missing_param("ref_id", "select_option");
1845                };
1846                let values_vec;
1847                let values: &[String] = match (&params.values, &params.value) {
1848                    (Some(v), _) => v,
1849                    (None, Some(v)) => {
1850                        values_vec = vec![v.clone()];
1851                        &values_vec
1852                    }
1853                    (None, None) => &[],
1854                };
1855                let values_json =
1856                    serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
1857                let code = format!(
1858                    "return window.__VICTAURI__?.selectOption({}, {})",
1859                    js_string(ref_id),
1860                    values_json
1861                );
1862                self.eval_page_action(&code, params.webview_label.as_deref())
1863                    .await
1864            }
1865        }
1866    }
1867
1868    #[tool(
1869        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].",
1870        annotations(
1871            read_only_hint = false,
1872            destructive_hint = false,
1873            idempotent_hint = false,
1874            open_world_hint = false
1875        )
1876    )]
1877    async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
1878        match params.action {
1879            InputAction::Fill => {
1880                if !self.state.privacy.is_tool_enabled("fill") {
1881                    return tool_disabled("fill");
1882                }
1883                let Some(ref_id) = &params.ref_id else {
1884                    return missing_param("ref_id", "fill");
1885                };
1886                let Some(value) = &params.value else {
1887                    return missing_param("value", "fill");
1888                };
1889                let code = format!(
1890                    "return window.__VICTAURI__?.fill({}, {})",
1891                    js_string(ref_id),
1892                    js_string(value)
1893                );
1894                self.eval_page_action(&code, params.webview_label.as_deref())
1895                    .await
1896            }
1897            InputAction::TypeText => {
1898                if !self.state.privacy.is_tool_enabled("type_text") {
1899                    return tool_disabled("type_text");
1900                }
1901                let Some(ref_id) = &params.ref_id else {
1902                    return missing_param("ref_id", "type_text");
1903                };
1904                let Some(text) = &params.text else {
1905                    return missing_param("text", "type_text");
1906                };
1907                if params.trusted.unwrap_or(false) {
1908                    // Focus the element via JS — and confirm focus landed on it, since the OS
1909                    // keystrokes go to whatever holds focus — then deliver real OS keystrokes
1910                    // (isTrusted: true) for handlers that reject synthetic events.
1911                    if let Err(refused) = self
1912                        .focus_for_trusted_input(ref_id, params.webview_label.as_deref())
1913                        .await
1914                    {
1915                        return refused;
1916                    }
1917                    let bridge = self.bridge.clone();
1918                    let label = params.webview_label.clone();
1919                    let text = text.to_string();
1920                    let native = tokio::task::spawn_blocking(move || {
1921                        bridge.native_type_text(label.as_deref(), &text)
1922                    })
1923                    .await
1924                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1925                    return match native {
1926                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1927                        Err(e) => tool_error(e),
1928                    };
1929                }
1930                let code = format!(
1931                    "return window.__VICTAURI__?.type({}, {})",
1932                    js_string(ref_id),
1933                    js_string(text)
1934                );
1935                self.eval_page_action(&code, params.webview_label.as_deref())
1936                    .await
1937            }
1938            InputAction::PressKey => {
1939                if !self.state.privacy.is_tool_enabled("input.press_key") {
1940                    return tool_disabled("input.press_key");
1941                }
1942                let Some(key) = &params.key else {
1943                    return missing_param("key", "press_key");
1944                };
1945                if params.trusted.unwrap_or(false) {
1946                    // Optionally focus a target element, then send a real OS key. A failed focus
1947                    // must stop here: the key would otherwise go to whatever holds focus.
1948                    if let Some(ref_id) = &params.ref_id
1949                        && let Err(refused) = self
1950                            .focus_for_trusted_input(ref_id, params.webview_label.as_deref())
1951                            .await
1952                    {
1953                        return refused;
1954                    }
1955                    let bridge = self.bridge.clone();
1956                    let label = params.webview_label.clone();
1957                    let key = key.to_string();
1958                    let native = tokio::task::spawn_blocking(move || {
1959                        bridge.native_key(label.as_deref(), &key)
1960                    })
1961                    .await
1962                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1963                    return match native {
1964                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1965                        Err(e) => tool_error(e),
1966                    };
1967                }
1968                let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
1969                self.eval_page_action(&code, params.webview_label.as_deref())
1970                    .await
1971            }
1972        }
1973    }
1974
1975    #[tool(
1976        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).",
1977        annotations(
1978            read_only_hint = false,
1979            destructive_hint = true,
1980            idempotent_hint = false,
1981            open_world_hint = false
1982        )
1983    )]
1984    async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
1985        match params.action {
1986            WindowAction::GetState => {
1987                let states = match self.bridge.try_get_window_states(params.label.as_deref()) {
1988                    Ok(states) => states,
1989                    Err(e) => return tool_error(ui_busy(&e)),
1990                };
1991                // A specific label that matches no window is an error, not an
1992                // empty array (which reads as "success, no state").
1993                if states.is_empty()
1994                    && let Some(label) = params.label.as_deref()
1995                {
1996                    return tool_error(format!(
1997                        "window not found: '{label}' (use window.list to see available labels)"
1998                    ));
1999                }
2000                json_result(&states)
2001            }
2002            WindowAction::List => match self.bridge.try_list_window_labels() {
2003                Ok(labels) => json_result(&labels),
2004                Err(e) => tool_error(ui_busy(&e)),
2005            },
2006            WindowAction::Introspectability => self.window_introspectability().await,
2007            WindowAction::Manage => {
2008                if !self.state.privacy.is_tool_enabled("window.manage") {
2009                    return tool_disabled("window.manage");
2010                }
2011                let Some(manage_action) = &params.manage_action else {
2012                    return missing_param("manage_action", "manage");
2013                };
2014                match self
2015                    .bridge
2016                    .manage_window(params.label.as_deref(), manage_action.as_str())
2017                {
2018                    Ok(msg) => CallToolResult::success(vec![ContentBlock::text(msg)]),
2019                    Err(e) => tool_error(e),
2020                }
2021            }
2022            WindowAction::Resize => {
2023                if !self.state.privacy.is_tool_enabled("window.resize") {
2024                    return tool_disabled("window.resize");
2025                }
2026                let Some(width) = params.width else {
2027                    return missing_param("width", "resize");
2028                };
2029                let Some(height) = params.height else {
2030                    return missing_param("height", "resize");
2031                };
2032                if width == 0 || height == 0 {
2033                    return tool_error_with_hint(
2034                        format!(
2035                            "invalid window size {width}x{height}: width and height must be > 0"
2036                        ),
2037                        RecoveryHint::CheckInput,
2038                    );
2039                }
2040                match self
2041                    .bridge
2042                    .resize_window(params.label.as_deref(), width, height)
2043                {
2044                    Ok(()) => {
2045                        let result =
2046                            serde_json::json!({"ok": true, "width": width, "height": height});
2047                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2048                    }
2049                    Err(e) => tool_error(e),
2050                }
2051            }
2052            WindowAction::MoveTo => {
2053                if !self.state.privacy.is_tool_enabled("window.move_to") {
2054                    return tool_disabled("window.move_to");
2055                }
2056                let Some(x) = params.x else {
2057                    return missing_param("x", "move_to");
2058                };
2059                let Some(y) = params.y else {
2060                    return missing_param("y", "move_to");
2061                };
2062                match self.bridge.move_window(params.label.as_deref(), x, y) {
2063                    Ok(()) => {
2064                        let result = serde_json::json!({"ok": true, "x": x, "y": y});
2065                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2066                    }
2067                    Err(e) => tool_error(e),
2068                }
2069            }
2070            WindowAction::SetTitle => {
2071                if !self.state.privacy.is_tool_enabled("window.set_title") {
2072                    return tool_disabled("window.set_title");
2073                }
2074                let Some(title) = &params.title else {
2075                    return missing_param("title", "set_title");
2076                };
2077                match self.bridge.set_window_title(params.label.as_deref(), title) {
2078                    Ok(()) => {
2079                        let result = serde_json::json!({"ok": true, "title": title});
2080                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2081                    }
2082                    Err(e) => tool_error(e),
2083                }
2084            }
2085        }
2086    }
2087
2088    #[tool(
2089        description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
2090        annotations(
2091            read_only_hint = false,
2092            destructive_hint = true,
2093            idempotent_hint = false,
2094            open_world_hint = false
2095        )
2096    )]
2097    async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
2098        match params.action {
2099            StorageAction::Get => {
2100                let method = match params.storage_type.unwrap_or(StorageType::Local) {
2101                    StorageType::Session => "getSessionStorage",
2102                    StorageType::Local => "getLocalStorage",
2103                };
2104                let key_arg = params
2105                    .key
2106                    .as_ref()
2107                    .map(|k| js_string(k))
2108                    .unwrap_or_default();
2109                let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
2110                self.eval_bridge(&code, params.webview_label.as_deref())
2111                    .await
2112            }
2113            StorageAction::Set => {
2114                if !self.state.privacy.is_tool_enabled("set_storage") {
2115                    return tool_disabled("set_storage");
2116                }
2117                let method = match params.storage_type.unwrap_or(StorageType::Local) {
2118                    StorageType::Session => "setSessionStorage",
2119                    StorageType::Local => "setLocalStorage",
2120                };
2121                let Some(key) = &params.key else {
2122                    return missing_param("key", "set");
2123                };
2124                // Operator-protected keys (auth/role/tier/flags) can't be poisoned
2125                // via storage.set (audit #33).
2126                if !self.state.privacy.is_storage_key_allowed(key) {
2127                    return tool_error(format!(
2128                        "storage key '{key}' is protected by privacy configuration"
2129                    ));
2130                }
2131                let value = params
2132                    .value
2133                    .as_ref()
2134                    .cloned()
2135                    .unwrap_or(serde_json::Value::Null);
2136                let value_json =
2137                    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
2138                let code = format!(
2139                    "return window.__VICTAURI__?.{method}({}, {value_json})",
2140                    js_string(key)
2141                );
2142                self.eval_bridge(&code, params.webview_label.as_deref())
2143                    .await
2144            }
2145            StorageAction::Delete => {
2146                if !self.state.privacy.is_tool_enabled("delete_storage") {
2147                    return tool_disabled("delete_storage");
2148                }
2149                let method = match params.storage_type.unwrap_or(StorageType::Local) {
2150                    StorageType::Session => "deleteSessionStorage",
2151                    StorageType::Local => "deleteLocalStorage",
2152                };
2153                let Some(key) = &params.key else {
2154                    return missing_param("key", "delete");
2155                };
2156                let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
2157                self.eval_bridge(&code, params.webview_label.as_deref())
2158                    .await
2159            }
2160            StorageAction::GetCookies => {
2161                self.eval_bridge(
2162                    "return window.__VICTAURI__?.getCookies()",
2163                    params.webview_label.as_deref(),
2164                )
2165                .await
2166            }
2167        }
2168    }
2169
2170    #[tool(
2171        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.",
2172        annotations(
2173            read_only_hint = false,
2174            destructive_hint = false,
2175            idempotent_hint = false,
2176            open_world_hint = false
2177        )
2178    )]
2179    async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
2180        match params.action {
2181            NavigateAction::GoTo => {
2182                if !self.state.privacy.is_tool_enabled("navigate") {
2183                    return tool_disabled("navigate");
2184                }
2185                let Some(url) = &params.url else {
2186                    return missing_param("url", "go_to");
2187                };
2188                if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
2189                    return tool_error(e);
2190                }
2191                let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
2192                self.eval_bridge(&code, params.webview_label.as_deref())
2193                    .await
2194            }
2195            NavigateAction::GoBack => {
2196                self.eval_bridge(
2197                    "return window.__VICTAURI__?.navigateBack()",
2198                    params.webview_label.as_deref(),
2199                )
2200                .await
2201            }
2202            NavigateAction::GetHistory => {
2203                self.eval_bridge(
2204                    "return window.__VICTAURI__?.getNavigationLog()",
2205                    params.webview_label.as_deref(),
2206                )
2207                .await
2208            }
2209            NavigateAction::SetDialogResponse => {
2210                if !self.state.privacy.is_tool_enabled("set_dialog_response") {
2211                    return tool_disabled("set_dialog_response");
2212                }
2213                let Some(dialog_type) = params.dialog_type else {
2214                    return missing_param("dialog_type", "set_dialog_response");
2215                };
2216                let Some(dialog_action) = params.dialog_action else {
2217                    return missing_param("dialog_action", "set_dialog_response");
2218                };
2219                let text_arg = params
2220                    .text
2221                    .as_ref()
2222                    .map_or_else(|| "undefined".to_string(), |t| js_string(t));
2223                let code = format!(
2224                    "return {}?.setDialogAutoResponse({}, {}, {text_arg})",
2225                    crate::js_bridge::agent_ops_js(),
2226                    js_string(dialog_type.as_str()),
2227                    js_string(dialog_action.as_str())
2228                );
2229                self.eval_bridge(&code, params.webview_label.as_deref())
2230                    .await
2231            }
2232            NavigateAction::GetDialogLog => {
2233                self.eval_bridge(
2234                    "return window.__VICTAURI__?.getDialogLog()",
2235                    params.webview_label.as_deref(),
2236                )
2237                .await
2238            }
2239        }
2240    }
2241
2242    #[tool(
2243        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).",
2244        annotations(
2245            read_only_hint = false,
2246            destructive_hint = true,
2247            idempotent_hint = false,
2248            open_world_hint = false
2249        )
2250    )]
2251    async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
2252        const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
2253        if !self.state.privacy.is_tool_enabled("recording") {
2254            return tool_disabled("recording");
2255        }
2256        match params.action {
2257            RecordingAction::Start => {
2258                let session_id = params
2259                    .session_id
2260                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
2261                let floor_ms = now_ms();
2262                match self.state.recorder.start_session(session_id.clone()) {
2263                    Ok(generation) => {
2264                        self.state.drain_watermarks.reset(floor_ms, generation);
2265                        let result = serde_json::json!({
2266                            "started": true,
2267                            "session_id": session_id,
2268                        });
2269                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2270                    }
2271                    Err(e) => tool_error(e.to_string()),
2272                }
2273            }
2274            RecordingAction::Stop => {
2275                // Final flush first: the background drain reads each window about once a second, so
2276                // anything the page captured since its last tick would otherwise be lost — and under
2277                // a busy UI no drain may have run at all. Best-effort and bounded; a window that
2278                // cannot answer in time is REPORTED, never silently dropped.
2279                let unreachable = if self.state.recorder.is_recording() {
2280                    self.final_recording_flush().await
2281                } else {
2282                    Vec::new()
2283                };
2284                match self.state.recorder.stop() {
2285                    Some(session) => {
2286                        if unreachable.is_empty() {
2287                            json_result(&session)
2288                        } else {
2289                            let mut value =
2290                                serde_json::to_value(&session).unwrap_or(serde_json::Value::Null);
2291                            if let Some(obj) = value.as_object_mut() {
2292                                obj.insert(
2293                                    "final_flush_unreachable".to_string(),
2294                                    serde_json::json!(unreachable),
2295                                );
2296                            }
2297                            json_result(&value)
2298                        }
2299                    }
2300                    None => tool_error("no recording is active"),
2301                }
2302            }
2303            RecordingAction::Checkpoint => {
2304                // checkpoint_id is optional — auto-generate a short id when the
2305                // caller just wants a positional marker. The id is echoed back in
2306                // the response so it can be referenced later
2307                // (events_between_checkpoints / replay).
2308                let id = params
2309                    .checkpoint_id
2310                    .unwrap_or_else(|| format!("cp-{}", uuid::Uuid::new_v4()));
2311                let state = params.state.unwrap_or(serde_json::Value::Null);
2312                match self
2313                    .state
2314                    .recorder
2315                    .checkpoint(id.clone(), params.checkpoint_label, state)
2316                {
2317                    Ok(()) => {
2318                        let result = serde_json::json!({
2319                            "created": true,
2320                            "checkpoint_id": id,
2321                            "event_index": self.state.recorder.event_count(),
2322                        });
2323                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2324                    }
2325                    Err(e) => tool_error(e.to_string()),
2326                }
2327            }
2328            RecordingAction::ListCheckpoints => {
2329                let checkpoints = self.state.recorder.get_checkpoints();
2330                json_result(&checkpoints)
2331            }
2332            RecordingAction::GetEvents => {
2333                let events = self
2334                    .state
2335                    .recorder
2336                    .events_since(params.since_index.unwrap_or(0));
2337                json_result(&events)
2338            }
2339            RecordingAction::EventsBetween => {
2340                let Some(from) = &params.from else {
2341                    return missing_param("from", "events_between");
2342                };
2343                let Some(to) = &params.to else {
2344                    return missing_param("to", "events_between");
2345                };
2346                match self.state.recorder.events_between_checkpoints(from, to) {
2347                    Ok(events) => json_result(&events),
2348                    Err(e) => tool_error(e.to_string()),
2349                }
2350            }
2351            RecordingAction::GetReplay => {
2352                let calls = self.state.recorder.ipc_replay_sequence();
2353                json_result(&calls)
2354            }
2355            RecordingAction::Export => match self.state.recorder.export() {
2356                Some(s) => {
2357                    let json = serde_json::to_string_pretty(&s)
2358                        .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
2359                    CallToolResult::success(vec![ContentBlock::text(json)])
2360                }
2361                None => tool_error("no recording is active — start one first"),
2362            },
2363            RecordingAction::Import => {
2364                let Some(session_json) = &params.session_json else {
2365                    return missing_param("session_json", "import");
2366                };
2367                if session_json.len() > MAX_SESSION_JSON {
2368                    return tool_error("session JSON exceeds maximum size (10 MB)");
2369                }
2370                let session: victauri_core::RecordedSession =
2371                    match serde_json::from_str(session_json) {
2372                        Ok(s) => s,
2373                        Err(e) => return tool_error(format!("invalid session JSON: {e}")),
2374                    };
2375
2376                let result = serde_json::json!({
2377                    "imported": true,
2378                    "session_id": session.id,
2379                    "event_count": session.events.len(),
2380                    "checkpoint_count": session.checkpoints.len(),
2381                    "started_at": session.started_at.to_rfc3339(),
2382                    "recording_active": true,
2383                    "note": "the imported session is now the ACTIVE recording: live events are \
2384                             appended to it until you call recording action=stop",
2385                });
2386                // Import REPLACES the active recording. Doing that silently threw away an
2387                // in-progress recording, so require it to be stopped (and saved) first — checked
2388                // and replaced in one step, so a recording started concurrently is never lost.
2389                let floor_ms = now_ms();
2390                match self.state.recorder.import_if_idle(session) {
2391                    Ok(generation) => self.state.drain_watermarks.reset(floor_ms, generation),
2392                    Err(_) => {
2393                        return tool_error(
2394                            "a recording is in progress — import would discard it. Stop it first \
2395                             (recording action=stop, or export it), then import.",
2396                        );
2397                    }
2398                }
2399                CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2400            }
2401            RecordingAction::Flush => {
2402                if !self.state.recorder.is_recording() {
2403                    return tool_error("no active recording — start a recording first");
2404                }
2405                // Same routine (and the same shared per-window watermark + lock) as the
2406                // background drain, so a flush never re-records what the drain already captured
2407                // and vice versa. With no label, every live window is flushed.
2408                let labels: Vec<String> = match params.webview_label.as_deref() {
2409                    Some(l) => vec![l.to_string()],
2410                    None => match self.bridge.try_list_window_labels() {
2411                        Ok(labels) => labels,
2412                        Err(e) => return tool_error(ui_busy(&e)),
2413                    },
2414                };
2415                let mut captured = 0usize;
2416                let mut failed = Vec::new();
2417                for label in &labels {
2418                    match crate::mcp::server::drain_window_into_recording(
2419                        &self.state,
2420                        &self.bridge,
2421                        label,
2422                    )
2423                    .await
2424                    {
2425                        Some(n) => captured += n,
2426                        None => failed.push(label.clone()),
2427                    }
2428                }
2429                if !labels.is_empty() && failed.len() == labels.len() {
2430                    return tool_error(format!(
2431                        "flush failed: no window answered ({})",
2432                        failed.join(", ")
2433                    ));
2434                }
2435                json_result(&serde_json::json!({
2436                    "flushed": true,
2437                    "events_captured": captured,
2438                    "windows": labels,
2439                    "unreachable_windows": failed,
2440                }))
2441            }
2442            RecordingAction::Replay => {
2443                let calls = self.state.recorder.ipc_replay_sequence();
2444                if calls.is_empty() {
2445                    return tool_error("no IPC calls recorded — record a session first");
2446                }
2447                let mut replay_results = Vec::new();
2448                for call in &calls {
2449                    // Recordings do not capture call ARGUMENTS, so a replay re-invokes each
2450                    // command with none. Re-running a call that took arguments is guaranteed
2451                    // wrong (it fails, or worse, runs with defaults), and re-running a call
2452                    // that failed or never completed reproduces nothing — skip both, loudly.
2453                    // A call a route rule answered in the page never reached the backend:
2454                    // replaying it would turn a fake (possibly page-forged) success into a real
2455                    // invocation.
2456                    let skip_reason = match &call.result {
2457                        _ if call.mocked => Some(
2458                            "the original call was answered by a route rule in the page, not the backend".to_string(),
2459                        ),
2460                        // Observed on Tauri's postMessage path: the bridge saw the call's
2461                        // serialization and response callback, which page script can stage
2462                        // without a real call, so its delivery is unproven.
2463                        _ if call.is_post_message() => Some(
2464                            "the original call was captured on Tauri's postMessage IPC path, where the bridge cannot prove it was really sent".to_string(),
2465                        ),
2466                        victauri_core::IpcResult::Ok(_) if call.arg_size_bytes > 0 => Some(
2467                            "the original call had arguments, which recordings do not capture".to_string(),
2468                        ),
2469                        victauri_core::IpcResult::Ok(_) => params
2470                            .webview_label
2471                            .as_deref()
2472                            .filter(|only| *only != call.webview_label)
2473                            .map(|only| {
2474                                format!(
2475                                    "recorded in window '{}'; replay was limited to '{only}'",
2476                                    call.webview_label
2477                                )
2478                            }),
2479                        victauri_core::IpcResult::Err(_) => {
2480                            Some("the original call failed".to_string())
2481                        }
2482                        _ => Some("the original call never completed".to_string()),
2483                    };
2484                    if let Some(reason) = skip_reason {
2485                        replay_results.push(serde_json::json!({
2486                            "command": call.command,
2487                            "status": "skipped",
2488                            "reason": reason,
2489                        }));
2490                        continue;
2491                    }
2492                    // Enforce the same command allow/blocklist as invoke_command
2493                    // (audit #30/#31): a recorded/imported session must not be able to
2494                    // invoke a command an operator blocked.
2495                    if !self.state.privacy.is_invoke_allowed(&call.command)
2496                        || !self.state.privacy.is_command_allowed(&call.command)
2497                    {
2498                        replay_results.push(serde_json::json!({
2499                            "command": call.command,
2500                            "status": "blocked",
2501                            "error": "blocked by privacy configuration",
2502                        }));
2503                        continue;
2504                    }
2505                    let code = format!(
2506                        "return window.__TAURI_INTERNALS__.invoke({})",
2507                        js_string(&call.command)
2508                    );
2509                    // Replay each call in the window that made it — never a default window: a
2510                    // command recorded in a low-privilege window must not run with main's
2511                    // capabilities. A window that no longer exists fails the call (an explicit
2512                    // label never falls back to another window).
2513                    let outcome = match self
2514                        .eval_with_return(&code, Some(call.webview_label.as_str()))
2515                        .await
2516                    {
2517                        Ok(result_str) => {
2518                            let value: serde_json::Value = serde_json::from_str(&result_str)
2519                                .unwrap_or(serde_json::Value::String(result_str));
2520                            let shape = crate::introspection::JsonShape::from_value(&value);
2521                            serde_json::json!({
2522                                "command": call.command,
2523                                "webview_label": call.webview_label,
2524                                "status": "ok",
2525                                "response_type": shape.type_name(),
2526                            })
2527                        }
2528                        Err(e) => {
2529                            serde_json::json!({
2530                                "command": call.command,
2531                                "status": "error",
2532                                "error": e,
2533                            })
2534                        }
2535                    };
2536                    replay_results.push(outcome);
2537                }
2538                let count = |status: &str| {
2539                    replay_results
2540                        .iter()
2541                        .filter(|r| r.get("status").and_then(|s| s.as_str()) == Some(status))
2542                        .count()
2543                };
2544                let (passed, skipped) = (count("ok"), count("skipped"));
2545                let replayed = replay_results.len() - skipped;
2546                let result = serde_json::json!({
2547                    "replayed": replayed,
2548                    "passed": passed,
2549                    "failed": replayed - passed,
2550                    "skipped": skipped,
2551                    "note": "commands are re-invoked WITHOUT arguments (recordings do not capture \
2552                             them), each in the window that recorded it, and their side effects \
2553                             happen again; calls that had arguments, failed, never completed, \
2554                             were answered by a route rule, or were captured on Tauri's \
2555                             postMessage IPC path are skipped",
2556                    "results": replay_results,
2557                });
2558                json_result(&result)
2559            }
2560        }
2561    }
2562
2563    #[tool(
2564        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).",
2565        annotations(
2566            read_only_hint = false,
2567            destructive_hint = false,
2568            idempotent_hint = true,
2569            open_world_hint = false
2570        )
2571    )]
2572    async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
2573        match params.action {
2574            InspectAction::GetStyles => {
2575                let Some(ref_id) = &params.ref_id else {
2576                    return missing_param("ref_id", "get_styles");
2577                };
2578                let props_arg = match &params.properties {
2579                    Some(props) => {
2580                        let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
2581                        format!("[{}]", arr.join(","))
2582                    }
2583                    None => "null".to_string(),
2584                };
2585                let code = format!(
2586                    "return window.__VICTAURI__?.getStyles({}, {})",
2587                    js_string(ref_id),
2588                    props_arg
2589                );
2590                self.eval_page_action(&code, params.webview_label.as_deref())
2591                    .await
2592            }
2593            InspectAction::GetBoundingBoxes => {
2594                let Some(ref_ids) = &params.ref_ids else {
2595                    return missing_param("ref_ids", "get_bounding_boxes");
2596                };
2597                let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
2598                let code = format!(
2599                    "return window.__VICTAURI__?.getBoundingBoxes([{}])",
2600                    refs.join(",")
2601                );
2602                self.eval_page_action(&code, params.webview_label.as_deref())
2603                    .await
2604            }
2605            InspectAction::Highlight => {
2606                // highlight injects a debug overlay node into the page — a DOM
2607                // mutation — so it is gated separately and excluded from the
2608                // read-only Observe profile (red-team P1).
2609                if !self.state.privacy.is_tool_enabled("inspect.highlight") {
2610                    return tool_disabled("inspect.highlight");
2611                }
2612                let Some(ref_id) = &params.ref_id else {
2613                    return missing_param("ref_id", "highlight");
2614                };
2615                let color_arg = match &params.color {
2616                    Some(c) => match sanitize_css_color(c) {
2617                        Ok(safe) => format!("\"{safe}\""),
2618                        Err(e) => return tool_error(e),
2619                    },
2620                    None => "null".to_string(),
2621                };
2622                let label_arg = match &params.label {
2623                    Some(l) => js_string(l),
2624                    None => "null".to_string(),
2625                };
2626                let code = format!(
2627                    "return window.__VICTAURI__?.highlightElement({}, {}, {})",
2628                    js_string(ref_id),
2629                    color_arg,
2630                    label_arg
2631                );
2632                self.eval_page_action(&code, params.webview_label.as_deref())
2633                    .await
2634            }
2635            InspectAction::ClearHighlights => {
2636                if !self
2637                    .state
2638                    .privacy
2639                    .is_tool_enabled("inspect.clear_highlights")
2640                {
2641                    return tool_disabled("inspect.clear_highlights");
2642                }
2643                self.eval_page_action(
2644                    "return window.__VICTAURI__?.clearHighlights()",
2645                    params.webview_label.as_deref(),
2646                )
2647                .await
2648            }
2649            InspectAction::AuditAccessibility => {
2650                self.eval_page_action(
2651                    "return window.__VICTAURI__?.auditAccessibility()",
2652                    params.webview_label.as_deref(),
2653                )
2654                .await
2655            }
2656            InspectAction::GetPerformance => {
2657                self.eval_page_action(
2658                    "return window.__VICTAURI__?.getPerformanceMetrics()",
2659                    params.webview_label.as_deref(),
2660                )
2661                .await
2662            }
2663        }
2664    }
2665
2666    #[tool(
2667        description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
2668        annotations(
2669            read_only_hint = false,
2670            destructive_hint = false,
2671            idempotent_hint = true,
2672            open_world_hint = false
2673        )
2674    )]
2675    async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
2676        match params.action {
2677            CssAction::Inject => {
2678                if !self.state.privacy.is_tool_enabled("inject_css") {
2679                    return tool_disabled("inject_css");
2680                }
2681                let Some(css) = &params.css else {
2682                    return missing_param("css", "inject");
2683                };
2684                // Block remote @import / url(...) exfil vectors unless explicitly opted in.
2685                if let Err(e) = sanitize_injected_css(css, params.allow_remote) {
2686                    return tool_error(e);
2687                }
2688                let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
2689                self.eval_bridge(&code, params.webview_label.as_deref())
2690                    .await
2691            }
2692            CssAction::Remove => {
2693                if !self.state.privacy.is_tool_enabled("css.remove") {
2694                    return tool_disabled("css.remove");
2695                }
2696                self.eval_bridge(
2697                    "return window.__VICTAURI__?.removeInjectedCss()",
2698                    params.webview_label.as_deref(),
2699                )
2700                .await
2701            }
2702        }
2703    }
2704
2705    #[tool(
2706        description = "Network request interception (Playwright route() equivalent, no CDP). \
2707            Matches webview fetch/XHR by URL and blocks, mocks, or delays them. \
2708            Actions:\n\
2709            - `add`: add a rule. `pattern` (+ optional `match_type`: substring/glob/regex/exact, \
2710              and `method`) selects requests; `behavior` is `block` (abort), `fulfill` (return a \
2711              mock `status`/`headers`/`body`/`content_type`), or `delay` (proceed after `delay_ms`). \
2712              `times` limits how often it fires. Rules are page-scoped (cleared on reload).\n\
2713            - `list`: list active rules.\n\
2714            - `clear` (by `id`) / `clear_all`: remove rules.\n\
2715            - `matches`: log of intercepted requests.\n\
2716            Note: fetch supports all behaviors; XHR supports block/delay (fulfill is fetch-only). \
2717            Top-level navigation, sub-resource (img/css), and WebSocket traffic are not intercepted. \
2718            Tauri IPC: on the fetch transport (desktop, until a fallback) a rule matching an IPC URL \
2719            (http(s)://ipc.localhost/<cmd> or ipc://localhost/<cmd>) DOES change what the app's real \
2720            `invoke` gets. `fulfill` mocks the command (the Rust handler never runs; invoke resolves \
2721            only with a `Tauri-Response: ok` header, else rejects with the body); `delay` holds the \
2722            call back; `block` does NOT fail it (Tauri retries over postMessage and stays there until \
2723            reload). Mocked calls are tagged `mocked` in the IPC log and kept out of ghost detection, \
2724            command_catalog, coverage and timings; `add` returns an `ipc_note` when a rule can match \
2725            IPC. postMessage IPC (Android, after a fallback) is not affected. The `fault` tool \
2726            instead acts on commands you drive via `invoke_command`.",
2727        annotations(
2728            read_only_hint = false,
2729            destructive_hint = false,
2730            idempotent_hint = false,
2731            open_world_hint = false
2732        )
2733    )]
2734    async fn route(&self, Parameters(params): Parameters<RouteParams>) -> CallToolResult {
2735        match params.action {
2736            RouteAction::Add => {
2737                if !self.state.privacy.is_tool_enabled("route.add") {
2738                    return tool_disabled("route.add");
2739                }
2740                let Some(pattern) = &params.pattern else {
2741                    return missing_param("pattern", "add");
2742                };
2743                let behavior = params.behavior.unwrap_or(RouteBehavior::Fulfill);
2744                let match_type = params.match_type.unwrap_or(RouteMatchType::Substring);
2745                let mut rule = serde_json::json!({
2746                    "pattern": pattern,
2747                    "match_type": match_type.as_str(),
2748                    "action": behavior.as_str(),
2749                });
2750                if let Some(m) = &params.method {
2751                    rule["method"] = serde_json::json!(m);
2752                }
2753                if let Some(s) = params.status {
2754                    rule["status"] = serde_json::json!(s);
2755                }
2756                if let Some(st) = &params.status_text {
2757                    rule["status_text"] = serde_json::json!(st);
2758                }
2759                if let Some(h) = &params.headers {
2760                    rule["headers"] = h.clone();
2761                }
2762                if let Some(b) = &params.body {
2763                    // A JSON string body is passed through as-is; structured JSON
2764                    // is stringified so the bridge sends valid JSON text.
2765                    rule["body"] = match b {
2766                        serde_json::Value::String(s) => serde_json::json!(s),
2767                        other => serde_json::json!(other.to_string()),
2768                    };
2769                }
2770                if let Some(ct) = &params.content_type {
2771                    rule["content_type"] = serde_json::json!(ct);
2772                }
2773                if let Some(d) = params.delay_ms {
2774                    // A delayed request is held this long in the page; cap it like a `fault`
2775                    // delay rather than accept any u64.
2776                    if d > MAX_FAULT_DELAY_MS {
2777                        return tool_error_with_hint(
2778                            format!("delay_ms {d} exceeds the maximum of {MAX_FAULT_DELAY_MS} ms"),
2779                            RecoveryHint::CheckInput,
2780                        );
2781                    }
2782                    rule["delay_ms"] = serde_json::json!(d);
2783                }
2784                if let Some(t) = params.times {
2785                    rule["times"] = serde_json::json!(t);
2786                }
2787                let code = format!(
2788                    "return window.__VICTAURI__?.addRoute({})",
2789                    js_string(&rule.to_string())
2790                );
2791                self.eval_page_action(&code, params.webview_label.as_deref())
2792                    .await
2793            }
2794            RouteAction::List => {
2795                self.eval_bridge(
2796                    "return window.__VICTAURI__?.getRouteRules()",
2797                    params.webview_label.as_deref(),
2798                )
2799                .await
2800            }
2801            RouteAction::Clear => {
2802                let Some(id) = params.id else {
2803                    return missing_param("id", "clear");
2804                };
2805                let code = format!(
2806                    "return {}?.clearRoute({id})",
2807                    crate::js_bridge::agent_ops_js()
2808                );
2809                self.eval_bridge(&code, params.webview_label.as_deref())
2810                    .await
2811            }
2812            RouteAction::ClearAll => {
2813                let code = format!("return {}?.clearRoutes()", crate::js_bridge::agent_ops_js());
2814                self.eval_bridge(&code, params.webview_label.as_deref())
2815                    .await
2816            }
2817            RouteAction::Matches => {
2818                let limit = params.limit.unwrap_or(100);
2819                // A maximum of 0 entries is none — not "all" (the bridge's falsy limit).
2820                if limit == 0 {
2821                    return CallToolResult::success(vec![ContentBlock::text("[]")]);
2822                }
2823                let code = format!("return window.__VICTAURI__?.getRouteMatches({limit})");
2824                self.eval_bridge(&code, params.webview_label.as_deref())
2825                    .await
2826            }
2827        }
2828    }
2829
2830    #[tool(
2831        description = "Screencast / visual trace (no CDP). Captures the window at a fixed interval \
2832            into a ring buffer, forming a visual timeline that pairs with `recording` (events) and \
2833            `logs` (network/console). Actions:\n\
2834            - `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set \
2835              `with_events=true` to also start the event recorder. Hidden windows are skipped \
2836              (no frame), the buffer is capped at 256 MB, and an abandoned trace auto-stops \
2837              after 30 minutes.\n\
2838            - `stop`: stop and return a summary (frame count, duration, timestamps); also stops \
2839              the recording that `with_events` started (never one started separately).\n\
2840            - `status`: active flag + buffered frame count.\n\
2841            - `frames`: return captured frames as base64 PNGs, newest first up to a 25 MB \
2842              response (`limit` caps how many).",
2843        annotations(
2844            read_only_hint = false,
2845            destructive_hint = false,
2846            idempotent_hint = false,
2847            open_world_hint = false
2848        )
2849    )]
2850    async fn trace(&self, Parameters(params): Parameters<TraceParams>) -> CallToolResult {
2851        if !self.state.privacy.is_tool_enabled("trace")
2852            || !self.state.privacy.is_tool_enabled("screenshot")
2853        {
2854            return tool_disabled("trace");
2855        }
2856        match params.action {
2857            TraceAction::Start => {
2858                let interval = params.interval_ms.unwrap_or(500);
2859                let max_frames = params.max_frames.unwrap_or(60);
2860                let label = params.webview_label.clone();
2861                // A trace started over a running one supersedes it: end the recording the
2862                // previous trace started (if it is still the active one), or it would be left
2863                // running with no owner (and the per-second drain loop with it).
2864                let (generation, superseded) =
2865                    self.state
2866                        .screencast
2867                        .start(interval, max_frames, label.clone());
2868                if let Some(prev) = superseded {
2869                    let _ = self.state.recorder.stop_if_generation(prev);
2870                }
2871
2872                let mut events_started = false;
2873                if params.with_events.unwrap_or(false) {
2874                    let session_id = uuid::Uuid::new_v4().to_string();
2875                    let floor_ms = now_ms();
2876                    if let Ok(recording) = self.state.recorder.start_session(session_id) {
2877                        self.state.drain_watermarks.reset(floor_ms, recording);
2878                        // Only a recording THIS trace started is stopped with it — by recorder
2879                        // generation, so a recording started later (even under the same session
2880                        // id) is never stopped by us. If this trace was already stopped or
2881                        // superseded meanwhile, nothing would ever stop the recording: do it now.
2882                        if self
2883                            .state
2884                            .screencast
2885                            .set_owned_recording(generation, recording)
2886                        {
2887                            events_started = true;
2888                        } else {
2889                            let _ = self.state.recorder.stop_if_generation(recording);
2890                        }
2891                    }
2892                }
2893
2894                // Background capture task: snapshot the window each interval until the
2895                // screencast is stopped, superseded by a newer start, or hits the max duration.
2896                // The guard ends the trace (and its recording) however the task exits — the
2897                // max-duration break, or a panic.
2898                let handler = self.clone();
2899                let screencast = self.state.screencast.clone();
2900                let guard = crate::screencast::CaptureTaskGuard {
2901                    screencast: Arc::clone(&screencast),
2902                    recorder: self.state.recorder.clone(),
2903                    generation,
2904                };
2905                tokio::spawn(async move {
2906                    let _guard = guard;
2907                    let t0 = std::time::Instant::now();
2908                    // An abandoned trace must not capture (and burn CPU) forever: past the max
2909                    // duration the loop ends and the guard stops it (only if no newer trace has
2910                    // started in the meantime).
2911                    while screencast.is_current(generation) && t0.elapsed() < MAX_TRACE_DURATION {
2912                        // Same visible-target rule as `screenshot`: a hidden window yields
2913                        // stale or another window's pixels, so skip the frame instead.
2914                        if let Ok(target) = handler.resolve_visible_capture_target(label.as_deref())
2915                            && let Ok(handle) = handler.bridge.get_native_handle(Some(&target))
2916                            && let Ok(png) = crate::screenshot::capture_window(handle).await
2917                        {
2918                            use base64::Engine;
2919                            let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2920                            #[allow(clippy::cast_possible_truncation)]
2921                            screencast.push_frame_if_current(
2922                                generation,
2923                                t0.elapsed().as_millis() as u64,
2924                                b64,
2925                            );
2926                        }
2927                        tokio::time::sleep(std::time::Duration::from_millis(
2928                            screencast.interval_ms(),
2929                        ))
2930                        .await;
2931                    }
2932                });
2933
2934                json_result(&serde_json::json!({
2935                    "started": true,
2936                    "interval_ms": self.state.screencast.interval_ms(),
2937                    "max_frames": max_frames.clamp(1, 600),
2938                    "with_events": events_started,
2939                }))
2940            }
2941            TraceAction::Stop => {
2942                let (frame_count, owned) = self.state.screencast.stop();
2943                let timestamps = self.state.screencast.frame_timestamps();
2944                let duration_ms = timestamps.last().copied().unwrap_or(0);
2945                // Stop the recording `with_events` started, so the recorder (and the
2946                // per-second drain loop it enables) does not outlive the trace. The session
2947                // stays readable via recording get_events/export (last stopped session).
2948                let event_count = match owned {
2949                    Some(owned) => self
2950                        .state
2951                        .recorder
2952                        .stop_if_generation(owned)
2953                        .map_or(0, |session| session.events.len()),
2954                    None => self.state.recorder.event_count(),
2955                };
2956                json_result(&serde_json::json!({
2957                    "stopped": true,
2958                    "frame_count": frame_count,
2959                    "duration_ms": duration_ms,
2960                    "frame_timestamps_ms": timestamps,
2961                    "recorded_event_count": event_count,
2962                    "hint": "use action=frames to retrieve PNGs; pair with recording/get_events and logs for a full bundle",
2963                }))
2964            }
2965            TraceAction::Status => json_result(&serde_json::json!({
2966                "active": self.state.screencast.is_active(),
2967                "frame_count": self.state.screencast.frame_count(),
2968                "interval_ms": self.state.screencast.interval_ms(),
2969            })),
2970            TraceAction::Frames => {
2971                let limit = params.limit.unwrap_or(0);
2972                let frames = self.state.screencast.frames(limit);
2973                if frames.is_empty() {
2974                    return json_result(&serde_json::json!({ "frames": 0 }));
2975                }
2976                // Bound the response: keep the NEWEST frames that fit the byte budget.
2977                let available = frames.len();
2978                let mut budget = MAX_TRACE_FRAMES_RESPONSE_BYTES;
2979                let mut kept: Vec<ContentBlock> = Vec::new();
2980                for f in frames.into_iter().rev() {
2981                    if f.data_b64.len() > budget && !kept.is_empty() {
2982                        break;
2983                    }
2984                    budget = budget.saturating_sub(f.data_b64.len());
2985                    kept.push(ContentBlock::image(f.data_b64, "image/png"));
2986                }
2987                kept.reverse();
2988                if kept.len() < available {
2989                    kept.insert(
2990                        0,
2991                        ContentBlock::text(format!(
2992                            "returned the newest {} of {available} frames (response capped at \
2993                             {} MB); pass a smaller `limit` to page",
2994                            kept.len(),
2995                            MAX_TRACE_FRAMES_RESPONSE_BYTES / (1024 * 1024)
2996                        )),
2997                    );
2998                }
2999                CallToolResult::success(kept)
3000            }
3001        }
3002    }
3003
3004    #[tool(
3005        description = "Animation introspection (no CDP). Reads the Web Animations API to reveal what \
3006            the webview's animation engine is actually running — duration, delay, easing, iterations, \
3007            keyframes, current progress, and the animating element. Standard DOM, so it works \
3008            identically on WebView2/WKWebView/WebKitGTK. Actions:\n\
3009            - `list`: return all running CSS animations/transitions (optionally scoped by `selector`), \
3010              each with declared `timing`, `computed` progress, `keyframes`, and `target`.\n\
3011            - `scrub`: deterministically pause the target's animation and seek it to `points` \
3012              evenly-spaced steps (default 20), returning the exact geometry curve (rect + transform \
3013              + opacity per step). With `capture=true`, also returns a single contact-sheet filmstrip \
3014              PNG (one image of the whole arc) plus a `manifest` mapping each cell to its progress/time. \
3015              Frozen frames are jank-free, so this beats real-time capture for fast sweeps. CSS-driven \
3016              animations only (JS/rAF animations are not seekable — use `list`/`sample`).\n\
3017            - `sample`: real-time motion recorder. `record=true` arms a requestAnimationFrame watcher \
3018              on `selector` (or the first animating element); then trigger the animation; then call \
3019              with `record=false` to read the measured per-frame curve plus jank stats (dropped frames, \
3020              max frame gap) and declared-vs-measured duration. Works for ANY animation including \
3021              JS/rAF-driven ones. `clear=true` resets recorded sessions.\n\
3022            NOTE: an animation only appears while it is running or pending — trigger it (e.g. show the \
3023            notification) just before calling `list`/`scrub`, or arm `sample` before triggering.",
3024        annotations(
3025            read_only_hint = false,
3026            destructive_hint = false,
3027            idempotent_hint = false,
3028            open_world_hint = false
3029        )
3030    )]
3031    async fn animation(&self, Parameters(params): Parameters<AnimationParams>) -> CallToolResult {
3032        if !self.state.privacy.is_tool_enabled("animation") {
3033            return tool_disabled("animation");
3034        }
3035        match params.action {
3036            AnimationAction::List => {
3037                let sel = params
3038                    .selector
3039                    .as_deref()
3040                    .map_or_else(|| "null".to_string(), js_string);
3041                let code = format!(
3042                    "return window.__VICTAURI__ && window.__VICTAURI__.listAnimations({sel})"
3043                );
3044                match self
3045                    .eval_with_return(&code, params.webview_label.as_deref())
3046                    .await
3047                {
3048                    Ok(result_str) => {
3049                        match serde_json::from_str::<serde_json::Value>(&result_str) {
3050                            Ok(v) => json_result(&v),
3051                            Err(_) => CallToolResult::success(vec![ContentBlock::text(result_str)]),
3052                        }
3053                    }
3054                    Err(e) => tool_error(format!("animation list failed: {e}")),
3055                }
3056            }
3057            AnimationAction::Scrub => {
3058                // `capture=true` takes native window screenshots (a filmstrip), so it needs the
3059                // `screenshot` tool as well — like `trace` — or an operator who disabled
3060                // screenshots would still get pixels through here. Refused before anything runs.
3061                if params.capture.unwrap_or(false)
3062                    && !self.state.privacy.is_tool_enabled("screenshot")
3063                {
3064                    return tool_error_with_hint(
3065                        "animation scrub with capture=true takes native window screenshots, but \
3066                         tool 'screenshot' is disabled by privacy configuration — call scrub \
3067                         without `capture` to get the geometry curve only",
3068                        RecoveryHint::ReportToUser,
3069                    );
3070                }
3071                self.animation_scrub(params).await
3072            }
3073            AnimationAction::Sample => {
3074                let label = params.webview_label.as_deref();
3075                let sel = params
3076                    .selector
3077                    .as_deref()
3078                    .map_or_else(|| "null".to_string(), js_string);
3079                let code = if params.record.unwrap_or(false) {
3080                    crate::js_bridge::agent_op_call_js(&format!("installSweepRecorder({sel})"))
3081                } else {
3082                    let clear = params.clear.unwrap_or(false);
3083                    crate::js_bridge::agent_op_call_js(&format!("readSweep({clear})"))
3084                };
3085                match self.eval_with_return(&code, label).await {
3086                    Ok(result_str) => {
3087                        match serde_json::from_str::<serde_json::Value>(&result_str) {
3088                            Ok(v) => json_result(&v),
3089                            Err(_) => CallToolResult::success(vec![ContentBlock::text(result_str)]),
3090                        }
3091                    }
3092                    Err(e) => tool_error(format!("animation sample failed: {e}")),
3093                }
3094            }
3095        }
3096    }
3097
3098    /// Deterministic pause-seek-capture loop for `animation scrub`. Split out to
3099    /// keep the `#[tool]` method readable.
3100    async fn animation_scrub(&self, params: AnimationParams) -> CallToolResult {
3101        let label = params.webview_label.as_deref();
3102        let sel = params
3103            .selector
3104            .as_deref()
3105            .map_or_else(|| "null".to_string(), js_string);
3106
3107        // 1. Prepare: pause the target's animations, learn the timeline length.
3108        let prep_code = crate::js_bridge::agent_op_call_js(&format!("scrubPrepare({sel})"));
3109        let prep_v = match self.eval_with_return(&prep_code, label).await {
3110            Ok(s) => {
3111                serde_json::from_str::<serde_json::Value>(&s).unwrap_or(serde_json::Value::Null)
3112            }
3113            Err(e) => return tool_error(format!("scrub prepare failed: {e}")),
3114        };
3115        if prep_v.get("prepared").and_then(serde_json::Value::as_bool) != Some(true) {
3116            // Surface the helpful error/info object (no target, JS-driven, etc.).
3117            return json_result(&prep_v);
3118        }
3119
3120        let points = params.points.unwrap_or(20).clamp(2, 120);
3121        let capture = params.capture.unwrap_or(false);
3122        let cols_for = |n: usize| {
3123            params
3124                .cols
3125                .unwrap_or_else(|| crate::filmstrip::default_cols(n))
3126        };
3127        let resume = params.restore.unwrap_or(true);
3128        let restore_code = crate::js_bridge::agent_op_call_js(&format!("scrubRestore({resume})"));
3129        let mut curve: Vec<serde_json::Value> = Vec::with_capacity(points);
3130        let mut frames: Vec<crate::filmstrip::Frame> = Vec::new();
3131        let mut manifest: Vec<serde_json::Value> = Vec::new();
3132        // The first reason a frame could not be captured (reported, never swallowed).
3133        let mut capture_error: Option<String> = None;
3134
3135        // 2. Seek to each evenly-spaced point; capture the frozen frame if asked.
3136        for i in 0..points {
3137            #[allow(clippy::cast_precision_loss)]
3138            let progress = i as f64 / (points - 1) as f64;
3139            let seek_code = crate::js_bridge::agent_op_call_js(&format!("scrubSeek({progress})"));
3140            match self.eval_with_return(&seek_code, label).await {
3141                Ok(s) => {
3142                    let v = serde_json::from_str::<serde_json::Value>(&s)
3143                        .unwrap_or(serde_json::Value::Null);
3144                    if capture {
3145                        match self.capture_scrub_frame(label).await {
3146                            Ok(frame) => {
3147                                // Before holding more frames: would the finished sheet be
3148                                // composable at all? (Raw RGBA frames accumulate until then.)
3149                                if frames.is_empty()
3150                                    && let Err(e) = crate::filmstrip::check_sheet(
3151                                        frame.w,
3152                                        frame.h,
3153                                        points,
3154                                        cols_for(points),
3155                                        FILMSTRIP_GAP,
3156                                    )
3157                                {
3158                                    let _ = self.eval_with_return(&restore_code, label).await;
3159                                    let fitting = (2..points).rev().find(|&n| {
3160                                        crate::filmstrip::check_sheet(
3161                                            frame.w,
3162                                            frame.h,
3163                                            n,
3164                                            cols_for(n),
3165                                            FILMSTRIP_GAP,
3166                                        )
3167                                        .is_ok()
3168                                    });
3169                                    let advice = fitting.map_or_else(
3170                                        || "call scrub without `capture` for the geometry curve                                             (or shrink the window)"
3171                                            .to_string(),
3172                                        |n| format!("use `points` <= {n} (or more `cols`)"),
3173                                    );
3174                                    return tool_error(format!(
3175                                        "animation scrub capture refused before capturing: {e};                                          {advice}"
3176                                    ));
3177                                }
3178                                manifest.push(serde_json::json!({
3179                                    "cell": frames.len(),
3180                                    "progress": progress,
3181                                    "t": v.get("t").cloned().unwrap_or(serde_json::Value::Null),
3182                                }));
3183                                frames.push(frame);
3184                            }
3185                            Err(e) => {
3186                                capture_error.get_or_insert(e);
3187                            }
3188                        }
3189                    }
3190                    curve.push(v);
3191                }
3192                Err(e) => curve.push(serde_json::json!({ "progress": progress, "error": e })),
3193            }
3194        }
3195
3196        // 3. Restore (resume) or leave paused.
3197        let _ = self.eval_with_return(&restore_code, label).await;
3198
3199        let mut meta = serde_json::json!({
3200            "scrubbed": true,
3201            "points": points,
3202            "duration_ms": prep_v.get("duration").cloned().unwrap_or(serde_json::Value::Null),
3203            "anim_count": prep_v.get("anim_count").cloned().unwrap_or(serde_json::Value::Null),
3204            "target": prep_v.get("target").cloned().unwrap_or(serde_json::Value::Null),
3205            // True only when a filmstrip is returned with this result.
3206            "captured": false,
3207            "curve": curve,
3208        });
3209        if let Some(e) = &capture_error {
3210            meta["capture_error"] = serde_json::json!(e);
3211        }
3212
3213        // 4. Compose the filmstrip if we captured frames.
3214        if capture && !frames.is_empty() {
3215            let cols = cols_for(frames.len());
3216            // Composing and encoding a sheet of up to hundreds of MB is seconds of CPU: off the
3217            // server's (two) async workers, or `/health` and every other call stall behind it.
3218            let composed = tokio::task::spawn_blocking(move || -> Result<_, String> {
3219                use base64::Engine;
3220                let (rgba, w, h) =
3221                    crate::filmstrip::compose(&frames, cols, FILMSTRIP_GAP, [20, 20, 20, 255])
3222                        .map_err(|e| format!("filmstrip compose failed: {e}"))?;
3223                drop(frames);
3224                let png = crate::screenshot::encode_png(w, h, &rgba)
3225                    .map_err(|e| format!("filmstrip encode failed: {e}"))?;
3226                Ok((base64::engine::general_purpose::STANDARD.encode(&png), w, h))
3227            })
3228            .await
3229            .unwrap_or_else(|e| Err(format!("filmstrip composition failed: {e}")));
3230            match composed {
3231                Ok((b64, w, h)) => {
3232                    meta["captured"] = serde_json::json!(true);
3233                    meta["filmstrip"] = serde_json::json!({
3234                        "cols": cols,
3235                        "frame_count": manifest.len(),
3236                        "width": w,
3237                        "height": h,
3238                        "manifest": manifest,
3239                    });
3240                    return CallToolResult::success(vec![
3241                        ContentBlock::image(b64, "image/png"),
3242                        ContentBlock::text(meta.to_string()),
3243                    ]);
3244                }
3245                Err(e) => return tool_error(e),
3246            }
3247        }
3248
3249        json_result(&meta)
3250    }
3251
3252    /// One native capture of the (frozen) scrub target window, as a filmstrip frame.
3253    async fn capture_scrub_frame(
3254        &self,
3255        label: Option<&str>,
3256    ) -> Result<crate::filmstrip::Frame, String> {
3257        let handle = self
3258            .bridge
3259            .get_native_handle(label)
3260            .map_err(|e| format!("no native window handle: {e}"))?;
3261        let (rgba, w, h) = crate::screenshot::capture_window_raw(handle)
3262            .await
3263            .map_err(|e| format!("window capture failed: {e}"))?;
3264        crate::filmstrip::Frame::new(rgba, w, h)
3265            .ok_or_else(|| format!("window capture returned a malformed {w}x{h} frame"))
3266    }
3267
3268    #[tool(
3269        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).",
3270        annotations(
3271            read_only_hint = false,
3272            destructive_hint = true,
3273            idempotent_hint = true,
3274            open_world_hint = false
3275        )
3276    )]
3277    async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
3278        // `limit` is the maximum number of entries to return, so 0 returns none (R5-JS5). The
3279        // page is not asked at all: `.slice(-0)` and the bridge's "falsy limit = everything"
3280        // used to turn it into EVERY entry, bodies included.
3281        if params.limit == Some(0)
3282            && matches!(
3283                params.action,
3284                LogsAction::Console
3285                    | LogsAction::Network
3286                    | LogsAction::Ipc
3287                    | LogsAction::Navigation
3288                    | LogsAction::Dialogs
3289                    | LogsAction::Events
3290            )
3291        {
3292            return CallToolResult::success(vec![ContentBlock::text("[]")]);
3293        }
3294        match params.action {
3295            LogsAction::Console => {
3296                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
3297                let base = if since_arg.is_empty() {
3298                    "window.__VICTAURI__?.getConsoleLogs()".to_string()
3299                } else {
3300                    format!("window.__VICTAURI__?.getConsoleLogs({since_arg})")
3301                };
3302                let code = if let Some(limit) = params.limit {
3303                    format!("return ({base} || []).slice(-{limit})")
3304                } else {
3305                    format!("return {base}")
3306                };
3307                self.eval_bridge(&code, params.webview_label.as_deref())
3308                    .await
3309            }
3310            LogsAction::Network => {
3311                let filter_arg = params
3312                    .filter
3313                    .as_ref()
3314                    .map_or_else(|| "null".to_string(), |f| js_string(f));
3315                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
3316                let source = format!("window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit})");
3317                let code = trimmed_log_js(&source, limit);
3318                self.eval_bridge(&code, params.webview_label.as_deref())
3319                    .await
3320            }
3321            LogsAction::Ipc => {
3322                let wait = params.wait_for_capture.unwrap_or(false);
3323                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
3324                if wait {
3325                    let inner =
3326                        trimmed_log_js(&format!("window.__VICTAURI__.getIpcLog({limit})"), limit);
3327                    let code = format!(
3328                        r"return (async function() {{
3329                            await window.__VICTAURI__.waitForIpcComplete(500);
3330                            return (function() {{ {inner} }})();
3331                        }})()"
3332                    );
3333                    let timeout = std::time::Duration::from_millis(5000);
3334                    match self
3335                        .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
3336                        .await
3337                    {
3338                        Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
3339                        Err(e) => tool_error(e),
3340                    }
3341                } else {
3342                    let code =
3343                        trimmed_log_js(&format!("window.__VICTAURI__?.getIpcLog({limit})"), limit);
3344                    self.eval_bridge(&code, params.webview_label.as_deref())
3345                        .await
3346                }
3347            }
3348            LogsAction::Navigation => {
3349                let code = if let Some(limit) = params.limit {
3350                    format!(
3351                        "return (window.__VICTAURI__?.getNavigationLog() || []).slice(-{limit})"
3352                    )
3353                } else {
3354                    "return window.__VICTAURI__?.getNavigationLog()".to_string()
3355                };
3356                self.eval_bridge(&code, params.webview_label.as_deref())
3357                    .await
3358            }
3359            LogsAction::Dialogs => {
3360                let code = if let Some(limit) = params.limit {
3361                    format!("return (window.__VICTAURI__?.getDialogLog() || []).slice(-{limit})")
3362                } else {
3363                    "return window.__VICTAURI__?.getDialogLog()".to_string()
3364                };
3365                self.eval_bridge(&code, params.webview_label.as_deref())
3366                    .await
3367            }
3368            LogsAction::Events => {
3369                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
3370                let base = if since_arg.is_empty() {
3371                    "window.__VICTAURI__?.getEventStream()".to_string()
3372                } else {
3373                    format!("window.__VICTAURI__?.getEventStream({since_arg})")
3374                };
3375                let code = if let Some(limit) = params.limit {
3376                    format!("return ({base} || []).slice(-{limit})")
3377                } else {
3378                    format!("return {base}")
3379                };
3380                self.eval_bridge(&code, params.webview_label.as_deref())
3381                    .await
3382            }
3383            LogsAction::SlowIpc => {
3384                let Some(threshold) = params.threshold_ms else {
3385                    return missing_param("threshold_ms", "slow_ipc");
3386                };
3387                let limit = params.limit.unwrap_or(20);
3388                let code = slow_ipc_js(threshold, limit);
3389                self.eval_bridge(&code, params.webview_label.as_deref())
3390                    .await
3391            }
3392            LogsAction::Clear => {
3393                // Clearing the IPC/network logs erases captured evidence — a
3394                // mutation of observable state — so it is gated separately and
3395                // excluded from the read-only Observe profile (red-team P1).
3396                if !self.state.privacy.is_tool_enabled("logs.clear") {
3397                    return tool_disabled("logs.clear");
3398                }
3399                let code = format!(
3400                    "return (function(){{ var b = {}; if (!b) return {{ ok:false, error:'bridge unavailable' }}; b.clearIpcLog(); b.clearNetworkLog(); return {{ ok:true, cleared:['ipc','network'] }}; }})()",
3401                    crate::js_bridge::agent_ops_js()
3402                );
3403                self.eval_bridge(&code, params.webview_label.as_deref())
3404                    .await
3405            }
3406        }
3407    }
3408
3409    // ── Backend Introspection ────────────────────────────────────────────────
3410
3411    #[tool(
3412        description = "Deep backend introspection — command profiling, IPC contract testing, \
3413            coverage, startup timing, capability auditing, database diagnostics, process \
3414            enumeration, and event bus monitoring. \
3415            These features exploit Victauri's position inside the Rust process.\n\n\
3416            Actions:\n\
3417            - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n\
3418            - `coverage`: Which registered commands have been called during this session.\n\
3419            - `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\
3420            - `contract_record`: Record a command's response shape as a baseline (requires `command`).\n\
3421            - `contract_check`: Check all recorded contracts for schema drift.\n\
3422            - `contract_list`: List all recorded contract baselines.\n\
3423            - `contract_clear`: Clear all recorded contract baselines.\n\
3424            - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
3425            - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
3426            - `db_health`: Read-only SQLite diagnostics: journal mode, page stats, freelist, \
3427              per-table row counts, and SQLite `quick_check` — each phase budgeted; a phase that \
3428              runs out is reported (`row_counts_complete`, `integrity_check: \"not completed…\"`) \
3429              instead of failing.\n\
3430            - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
3431            - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
3432            - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
3433            - `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\
3434            - `event_bus_clear`: Clear the event bus capture buffer.",
3435        annotations(
3436            read_only_hint = false,
3437            destructive_hint = true,
3438            idempotent_hint = false,
3439            open_world_hint = false
3440        )
3441    )]
3442    async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
3443        if !self.state.privacy.is_tool_enabled("introspect") {
3444            return tool_disabled("introspect");
3445        }
3446
3447        match params.action {
3448            IntrospectAction::CommandTimings => {
3449                let mut stats = self.state.command_timings.all_stats();
3450                let driven_count = stats.len();
3451                if let Some(threshold) = params.slow_threshold_ms {
3452                    stats.retain(|s| s.avg_ms >= threshold);
3453                }
3454
3455                // Real frontend traffic: derive per-command latency from the live IPC
3456                // log so the profiler is not blind to commands the app itself drives.
3457                // `command_timings` (above) only records Victauri-driven invoke_command
3458                // calls — on a running app that counter is typically 0 while the app
3459                // makes hundreds of real calls. The IPC log captures those with
3460                // duration; the name+duration projection stays under the eval cap.
3461                let code = ipc_timing_projection_js(None);
3462                let mut ipc_traffic = match self
3463                    .eval_with_return(&code, params.webview_label.as_deref())
3464                    .await
3465                {
3466                    Ok(json_str) => page_json::parse_page_json::<Vec<serde_json::Value>>(&json_str)
3467                        .map(|entries| ipc_timing_stats(&entries))
3468                        .unwrap_or_default(),
3469                    Err(_) => Vec::new(),
3470                };
3471                if let Some(threshold) = params.slow_threshold_ms {
3472                    ipc_traffic.retain(|s| {
3473                        s.get("avg_ms")
3474                            .and_then(serde_json::Value::as_f64)
3475                            .is_some_and(|a| a >= threshold)
3476                    });
3477                }
3478
3479                let ipc_capture = self
3480                    .ipc_capture_status(params.webview_label.as_deref())
3481                    .await;
3482                let result = serde_json::json!({
3483                    "commands": stats,
3484                    "total_commands_profiled": driven_count,
3485                    "ipc_capture": ipc_capture,
3486                    // The store stops tracking NEW command names at its cap; say so rather
3487                    // than let a missing command look like one that never ran.
3488                    "saturated": driven_count >= COMMAND_TIMINGS_CAP,
3489                    "ipc_traffic": ipc_traffic,
3490                    "ipc_commands_observed": ipc_traffic.len(),
3491                    "slow_threshold_ms": params.slow_threshold_ms,
3492                    "note": "`commands` profiles ONLY commands you drove through Victauri's \
3493                             invoke_command tool (often empty on a live app). `ipc_traffic` \
3494                             profiles the app's REAL frontend IPC, derived from the live IPC \
3495                             log (per-command call_count + min/max/avg/p95 latency) — that is \
3496                             the one reflecting actual usage.",
3497                });
3498                json_result(&result)
3499            }
3500            IntrospectAction::Coverage => {
3501                let registered: Vec<String> = self
3502                    .state
3503                    .registry
3504                    .list()
3505                    .iter()
3506                    .map(|c| c.name.clone())
3507                    .collect();
3508
3509                // Project to command NAMES ONLY. The previous full `getIpcLog()` carried
3510                // request/response bodies and blew the eval result cap on busy apps,
3511                // silently returning an empty set and reporting "0 invoked" despite live
3512                // traffic. This is the same name projection ghost detection uses.
3513                let code = ghost_ipc_projection_js(None);
3514                let (invoked, ipc_calls_observed): (std::collections::HashSet<String>, usize) =
3515                    match self
3516                        .eval_with_return(&code, params.webview_label.as_deref())
3517                        .await
3518                    {
3519                        Ok(json_str) => {
3520                            match page_json::parse_page_json::<Vec<String>>(&json_str) {
3521                                Ok(names) => {
3522                                    let count = names.len();
3523                                    (names.into_iter().collect(), count)
3524                                }
3525                                Err(_) => (std::collections::HashSet::new(), 0),
3526                            }
3527                        }
3528                        Err(_) => (std::collections::HashSet::new(), 0),
3529                    };
3530
3531                let uncovered: Vec<&String> = registered
3532                    .iter()
3533                    .filter(|cmd| !invoked.contains(cmd.as_str()))
3534                    .collect();
3535
3536                let coverage_pct = if registered.is_empty() {
3537                    100.0
3538                } else {
3539                    let covered = registered.len() - uncovered.len();
3540                    (covered as f64 / registered.len() as f64) * 100.0
3541                };
3542
3543                let note = if registered.is_empty() {
3544                    Some(
3545                        "The introspection registry is empty (the app does not use \
3546                         #[inspectable]/register_command_names), so coverage_pct is a \
3547                         placeholder 100%. `invoked_not_registered` still lists the real \
3548                         commands seen on the live IPC log — use it to inventory actual \
3549                         traffic.",
3550                    )
3551                } else if ipc_calls_observed == 0 {
3552                    Some(
3553                        "No IPC calls were observed on the live log. If the app is actively \
3554                         making calls, confirm the target webview and check `ipc_capture`: \
3555                         a `capture` other than `full` means the IPC picture is partial.",
3556                    )
3557                } else {
3558                    None
3559                };
3560                let ipc_capture = self
3561                    .ipc_capture_status(params.webview_label.as_deref())
3562                    .await;
3563
3564                let result = serde_json::json!({
3565                    "ipc_capture": ipc_capture,
3566                    "registered_commands": registered.len(),
3567                    "invoked_commands": invoked.len(),
3568                    "ipc_calls_observed": ipc_calls_observed,
3569                    "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
3570                    "uncovered": uncovered,
3571                    "invoked_not_registered": invoked.iter()
3572                        .filter(|cmd| !registered.contains(cmd))
3573                        .collect::<Vec<_>>(),
3574                    "note": note,
3575                });
3576                json_result(&result)
3577            }
3578            IntrospectAction::CommandCatalog => {
3579                // Mine the live IPC log for per-command argument + result SHAPES (inferred
3580                // in JS, bodies never shipped — so it stays under the eval cap on busy apps)
3581                // and merge with the #[inspectable] registry. This is the answer to a real
3582                // live gap: an app without #[inspectable] (e.g. 4DA — 379 commands, every
3583                // registry field null) gives an agent command NAMES but no call/return
3584                // schema; the IPC log holds the actual shapes, so we project them out.
3585                let code = ipc_catalog_projection_js();
3586                let ipc_entries: Vec<serde_json::Value> = match self
3587                    .eval_with_return(&code, params.webview_label.as_deref())
3588                    .await
3589                {
3590                    Ok(json_str) => page_json::parse_page_json(&json_str).unwrap_or_default(),
3591                    Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
3592                };
3593
3594                let registry = self.state.registry.list();
3595                let catalog = merge_command_catalog(&ipc_entries, &registry);
3596                let observed = catalog
3597                    .iter()
3598                    .filter(|c| {
3599                        c.get("observed")
3600                            .and_then(serde_json::Value::as_bool)
3601                            .unwrap_or(false)
3602                    })
3603                    .count();
3604                let ipc_capture = self
3605                    .ipc_capture_status(params.webview_label.as_deref())
3606                    .await;
3607
3608                let result = serde_json::json!({
3609                    "ipc_capture": ipc_capture,
3610                    "catalog": catalog,
3611                    "observed_count": observed,
3612                    "registered_count": registry.len(),
3613                    "total": catalog.len(),
3614                    "note": "`arg_shape`/`result_shape` are STRUCTURES inferred from the live \
3615                             IPC log (keys + value types, not values) — how to CALL each command \
3616                             and what it RETURNS, even for apps that don't use #[inspectable]. \
3617                             `observed:false` means the command is in the registry but hasn't \
3618                             been seen on the wire this session (drive the app's UI to populate \
3619                             its shape). `declared_*` fields, when present, come from \
3620                             #[inspectable] and are authoritative over the inferred shape.",
3621                });
3622                json_result(&result)
3623            }
3624            IntrospectAction::ContractRecord => {
3625                let Some(command) = params.command else {
3626                    return missing_param("command", "contract_record");
3627                };
3628                // contract_record invokes the command with caller-supplied args, so
3629                // it must honour the same allow/blocklist as invoke_command (audit #30).
3630                if !self.state.privacy.is_invoke_allowed(&command)
3631                    || !self.state.privacy.is_command_allowed(&command)
3632                {
3633                    return tool_error(format!(
3634                        "command '{command}' is blocked by privacy configuration"
3635                    ));
3636                }
3637                let args_json = params.args.unwrap_or(serde_json::json!({}));
3638                let args_str =
3639                    serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
3640                let code = format!(
3641                    "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
3642                    js_string(&command)
3643                );
3644                match self
3645                    .eval_with_return(&code, params.webview_label.as_deref())
3646                    .await
3647                {
3648                    Ok(result_str) => {
3649                        let value: serde_json::Value = serde_json::from_str(&result_str)
3650                            .unwrap_or(serde_json::Value::String(result_str.clone()));
3651                        let shape = crate::introspection::JsonShape::from_value(&value);
3652                        let sample = if result_str.len() > 4096 {
3653                            format!(
3654                                "{}...(truncated)",
3655                                truncate_at_char_boundary(&result_str, 4096)
3656                            )
3657                        } else {
3658                            result_str
3659                        };
3660                        let baseline = crate::introspection::ContractBaseline {
3661                            command: command.clone(),
3662                            args: args_json,
3663                            shape: shape.clone(),
3664                            sample,
3665                            recorded_at: chrono_now(),
3666                        };
3667                        self.state.contract_store.record(baseline);
3668                        let result = serde_json::json!({
3669                            "recorded": true,
3670                            "command": command,
3671                            "shape_type": shape.type_name(),
3672                        });
3673                        json_result(&result)
3674                    }
3675                    Err(e) => tool_error(format!(
3676                        "failed to invoke '{command}' for contract recording: {e}"
3677                    )),
3678                }
3679            }
3680            IntrospectAction::ContractCheck => {
3681                let baselines = self.state.contract_store.all();
3682                if baselines.is_empty() {
3683                    return json_result(&serde_json::json!({
3684                        "checked": 0,
3685                        "message": "no contract baselines recorded — use contract_record first",
3686                    }));
3687                }
3688                let mut results = Vec::new();
3689                for baseline in &baselines {
3690                    // Re-checking a baseline re-invokes the command; honour the
3691                    // allow/blocklist in case it changed since recording (audit #30).
3692                    if !self.state.privacy.is_invoke_allowed(&baseline.command)
3693                        || !self.state.privacy.is_command_allowed(&baseline.command)
3694                    {
3695                        continue;
3696                    }
3697                    let args_str =
3698                        serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
3699                    let code = format!(
3700                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
3701                        js_string(&baseline.command)
3702                    );
3703                    match self
3704                        .eval_with_return(&code, params.webview_label.as_deref())
3705                        .await
3706                    {
3707                        Ok(result_str) => {
3708                            let value: serde_json::Value = serde_json::from_str(&result_str)
3709                                .unwrap_or(serde_json::Value::String(result_str));
3710                            let current_shape = crate::introspection::JsonShape::from_value(&value);
3711                            let drift = crate::introspection::diff_shapes(
3712                                &baseline.shape,
3713                                &current_shape,
3714                                &baseline.command,
3715                            );
3716                            results.push(drift);
3717                        }
3718                        Err(e) => {
3719                            results.push(crate::introspection::ContractDrift {
3720                                command: baseline.command.clone(),
3721                                new_fields: Vec::new(),
3722                                removed_fields: Vec::new(),
3723                                type_changes: Vec::new(),
3724                                shape_matches: false,
3725                            });
3726                            tracing::warn!(
3727                                command = %baseline.command,
3728                                error = %e,
3729                                "contract check invocation failed"
3730                            );
3731                        }
3732                    }
3733                }
3734                let passing = results.iter().filter(|r| r.shape_matches).count();
3735                let result = serde_json::json!({
3736                    "checked": results.len(),
3737                    "passing": passing,
3738                    "failing": results.len() - passing,
3739                    "contracts": results,
3740                });
3741                json_result(&result)
3742            }
3743            IntrospectAction::ContractList => {
3744                let baselines = self.state.contract_store.all();
3745                let result = serde_json::json!({
3746                    "count": baselines.len(),
3747                    "baselines": baselines.iter().map(|b| serde_json::json!({
3748                        "command": b.command,
3749                        "shape_type": b.shape.type_name(),
3750                        "recorded_at": b.recorded_at,
3751                    })).collect::<Vec<_>>(),
3752                });
3753                json_result(&result)
3754            }
3755            IntrospectAction::ContractClear => {
3756                let cleared = self.state.contract_store.clear();
3757                json_result(&serde_json::json!({
3758                    "cleared": cleared,
3759                }))
3760            }
3761            IntrospectAction::StartupTiming => {
3762                let phases = self.state.startup_timeline.report();
3763                let result = serde_json::json!({
3764                    "phases": phases,
3765                    "total_ms": self.state.startup_timeline.total_ms(),
3766                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
3767                });
3768                json_result(&result)
3769            }
3770            IntrospectAction::Capabilities => {
3771                let config = self.bridge.tauri_config();
3772                // A busy UI is reported as such, never as "no live windows".
3773                let (live_windows, live_windows_error) = match self.bridge.try_list_window_labels()
3774                {
3775                    Ok(labels) => (Some(labels), None),
3776                    Err(e) => (None, Some(ui_busy(&e))),
3777                };
3778
3779                let result = serde_json::json!({
3780                    "app": {
3781                        "identifier": config.get("identifier"),
3782                        "product_name": config.get("product_name"),
3783                        "version": config.get("version"),
3784                    },
3785                    "security": config.get("security"),
3786                    "configured_windows": config.get("windows"),
3787                    "live_windows": live_windows,
3788                    "live_windows_error": live_windows_error,
3789                    "configured_plugins": config.get("plugins"),
3790                    "victauri": {
3791                        "registered_commands": self.state.registry.list().len(),
3792                        "redaction_enabled": self.state.privacy.redaction_enabled,
3793                        "privacy_profile": format!("{:?}", self.state.privacy.profile),
3794                        "disabled_tools": &self.state.privacy.disabled_tools,
3795                    },
3796                });
3797                json_result(&result)
3798            }
3799            #[allow(unused_variables)]
3800            IntrospectAction::DbHealth => {
3801                #[cfg(feature = "sqlite")]
3802                {
3803                    let db_path = params.db_path.clone();
3804                    match self.run_db_health(db_path.as_deref()).await {
3805                        Ok(health) => json_result(&health),
3806                        Err(e) => tool_error(format!("db_health failed: {e}")),
3807                    }
3808                }
3809                #[cfg(not(feature = "sqlite"))]
3810                {
3811                    tool_error("SQLite support not compiled in — enable the `sqlite` feature")
3812                }
3813            }
3814            IntrospectAction::PluginState => {
3815                let recording_active = self.state.recorder.is_recording();
3816                let recording_events = self.state.recorder.event_count();
3817                let result = serde_json::json!({
3818                    "event_log": {
3819                        "size": self.state.event_log.len(),
3820                        "capacity": self.state.event_log.capacity(),
3821                    },
3822                    "registry": {
3823                        "commands_registered": self.state.registry.list().len(),
3824                    },
3825                    "recording": {
3826                        "active": recording_active,
3827                        "events_captured": recording_events,
3828                    },
3829                    "faults": {
3830                        "active_rules": self.state.fault_registry.list().len(),
3831                    },
3832                    "contracts": {
3833                        "baselines_recorded": self.state.contract_store.all().len(),
3834                    },
3835                    "timings": {
3836                        "commands_profiled": self.state.command_timings.all_stats().len(),
3837                    },
3838                    "event_bus": {
3839                        "captured_events": self.state.event_bus.len(),
3840                    },
3841                    "tasks": {
3842                        "total": self.state.task_tracker.list().len(),
3843                        "active": self.state.task_tracker.active_count(),
3844                    },
3845                    "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
3846                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
3847                    "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
3848                });
3849                json_result(&result)
3850            }
3851            IntrospectAction::Processes => {
3852                let pid = std::process::id();
3853                let uptime = self.state.started_at.elapsed();
3854                let children = crate::introspection::enumerate_child_processes();
3855                let host_memory = crate::memory::current_stats();
3856
3857                let result = serde_json::json!({
3858                    "host": {
3859                        "pid": pid,
3860                        "uptime_secs": uptime.as_secs(),
3861                        "platform": std::env::consts::OS,
3862                        "arch": std::env::consts::ARCH,
3863                        "memory": host_memory,
3864                    },
3865                    "children": children.iter().map(|c| serde_json::json!({
3866                        "pid": c.pid,
3867                        "name": c.name,
3868                        "memory_bytes": c.memory_bytes,
3869                    })).collect::<Vec<_>>(),
3870                    "child_count": children.len(),
3871                    "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
3872                });
3873                json_result(&result)
3874            }
3875            IntrospectAction::PluginTasks => {
3876                let tasks = self.state.task_tracker.list();
3877                let active = self.state.task_tracker.active_count();
3878                let result = serde_json::json!({
3879                    "total": tasks.len(),
3880                    "active": active,
3881                    "finished": tasks.len() - active,
3882                    "tasks": tasks,
3883                });
3884                json_result(&result)
3885            }
3886            IntrospectAction::EventBus => {
3887                // Default cap so the full buffers (up to 1k Tauri + 10k app events, often
3888                // megabytes / tens of thousands of lines) can never overflow the tool result
3889                // cap (VIC-4). Newest events first; `count` is the full total so a truncated
3890                // slice is always diagnosable. Optional `limit` / `since_ms` are read from the
3891                // generic `args` object (a dedicated public field would be a semver-major break).
3892                let opts = params.args.as_ref();
3893                let limit = opts
3894                    .and_then(|a| a.get("limit"))
3895                    .and_then(serde_json::Value::as_u64)
3896                    .and_then(|n| usize::try_from(n).ok())
3897                    .unwrap_or(100);
3898                let since_ms = opts
3899                    .and_then(|a| a.get("since_ms"))
3900                    .and_then(serde_json::Value::as_u64);
3901                let cutoff = since_ms.map(|ms| bounded::ms_ago(chrono::Utc::now(), ms));
3902
3903                let all_tauri = self.state.event_bus.events();
3904                let tauri_total = all_tauri.len();
3905                let tauri_matched: Vec<_> = all_tauri
3906                    .into_iter()
3907                    .filter(|e| match cutoff {
3908                        Some(cut) => chrono::DateTime::parse_from_rfc3339(&e.timestamp)
3909                            .map_or(true, |t| t.with_timezone(&chrono::Utc) >= cut),
3910                        None => true,
3911                    })
3912                    .collect();
3913                let tauri_matched_count = tauri_matched.len();
3914                let tauri_events: Vec<_> = tauri_matched.into_iter().rev().take(limit).collect();
3915
3916                // Exclude Victauri's own infrastructure events (plugin:victauri|* IPC etc.) —
3917                // noise in a diagnostic timeline; the `explain` tools already filter them via
3918                // `is_internal()`.
3919                let all_app: Vec<_> = self
3920                    .state
3921                    .event_log
3922                    .snapshot()
3923                    .into_iter()
3924                    .filter(|e| !e.is_internal())
3925                    .collect();
3926                let app_total = all_app.len();
3927                let app_matched: Vec<_> = match cutoff {
3928                    Some(cut) => all_app
3929                        .into_iter()
3930                        .filter(|e| e.timestamp() >= cut)
3931                        .collect(),
3932                    None => all_app,
3933                };
3934                let app_matched_count = app_matched.len();
3935                let app_events: Vec<_> = app_matched.into_iter().rev().take(limit).collect();
3936
3937                let result = serde_json::json!({
3938                    "limit": limit,
3939                    "since_ms": since_ms,
3940                    "tauri_events": {
3941                        "count": tauri_total,
3942                        "matched": tauri_matched_count,
3943                        "returned": tauri_events.len(),
3944                        "truncated": tauri_matched_count > tauri_events.len(),
3945                        "events": tauri_events,
3946                    },
3947                    "app_events": {
3948                        "count": app_total,
3949                        "matched": app_matched_count,
3950                        "returned": app_events.len(),
3951                        "truncated": app_matched_count > app_events.len(),
3952                        "capacity": self.state.event_log.capacity(),
3953                        "events": app_events,
3954                    },
3955                });
3956                json_result(&result)
3957            }
3958            IntrospectAction::EventBusClear => {
3959                let tauri_cleared = self.state.event_bus.clear();
3960                self.state.event_log.clear();
3961                json_result(&serde_json::json!({
3962                    "tauri_events_cleared": tauri_cleared,
3963                    "app_events_cleared": true,
3964                }))
3965            }
3966        }
3967    }
3968
3969    // ── Fault Injection / Chaos Engineering ──────────────────────────────────
3970
3971    #[tool(
3972        description = "Probe a backend command handler under failure by faulting it for chaos engineering. \
3973            Simulate slow commands, backend errors, dropped responses, and corrupted data. \
3974            SCOPE: faults apply ONLY to commands you run via this server's `invoke_command` tool — \
3975            they do NOT touch the app's real user-driven IPC (window.__TAURI_INTERNALS__.invoke). Use \
3976            this to test a handler's error path when YOU drive it. To make a call the user's click \
3977            triggers fail or slow down, use a `route` rule on its IPC URL instead (fetch transport \
3978            only).\n\n\
3979            Actions:\n\
3980            - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms` \
3981              (max 120000), `error_message`, `max_triggers`. `delay` sleeps then runs the command; \
3982              `error` returns the error without running it; `drop` returns `{}` without running \
3983              it; `corrupt` runs it and replaces the response with a fixed \
3984              `{\"__corrupted\":true,…}` marker. Rules expire after 15 minutes.\n\
3985            - `list`: List all active fault injection rules.\n\
3986            - `clear`: Remove a specific fault rule (requires `command`).\n\
3987            - `clear_all`: Remove all fault rules.",
3988        annotations(
3989            read_only_hint = false,
3990            destructive_hint = true,
3991            idempotent_hint = false,
3992            open_world_hint = false
3993        )
3994    )]
3995    async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
3996        if !self.state.privacy.is_tool_enabled("fault") {
3997            return tool_disabled("fault");
3998        }
3999
4000        match params.action {
4001            FaultAction::Inject => {
4002                let Some(command) = params.command else {
4003                    return missing_param("command", "inject");
4004                };
4005                let Some(fault_kind) = params.fault_type else {
4006                    return missing_param("fault_type", "inject");
4007                };
4008                let fault_type = match fault_kind {
4009                    FaultKind::Delay => {
4010                        let delay_ms = params.delay_ms.unwrap_or(1000);
4011                        // Each delayed call holds a server concurrency slot for its duration;
4012                        // an unbounded delay (u64 ms) would pin slots effectively forever.
4013                        if delay_ms > MAX_FAULT_DELAY_MS {
4014                            return tool_error(format!(
4015                                "delay_ms {delay_ms} exceeds the maximum of {MAX_FAULT_DELAY_MS} ms"
4016                            ));
4017                        }
4018                        crate::introspection::FaultType::Delay { delay_ms }
4019                    }
4020                    FaultKind::Error => {
4021                        let message = params
4022                            .error_message
4023                            .unwrap_or_else(|| "injected fault".to_string());
4024                        crate::introspection::FaultType::Error { message }
4025                    }
4026                    FaultKind::Drop => crate::introspection::FaultType::Drop,
4027                    FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
4028                };
4029                let config = crate::introspection::FaultConfig {
4030                    command: command.clone(),
4031                    fault_type: fault_type.clone(),
4032                    trigger_count: 0,
4033                    max_triggers: params.max_triggers.unwrap_or(0),
4034                    created_at: std::time::Instant::now(),
4035                };
4036                self.state.fault_registry.inject(config);
4037                let result = serde_json::json!({
4038                    "injected": true,
4039                    "command": command,
4040                    "fault_type": fault_type,
4041                    "max_triggers": params.max_triggers.unwrap_or(0),
4042                });
4043                json_result(&result)
4044            }
4045            FaultAction::List => {
4046                let faults = self.state.fault_registry.list();
4047                let result = serde_json::json!({
4048                    "count": faults.len(),
4049                    "faults": faults.iter().map(|f| serde_json::json!({
4050                        "command": f.command,
4051                        "fault_type": f.fault_type,
4052                        "trigger_count": f.trigger_count,
4053                        "max_triggers": f.max_triggers,
4054                    })).collect::<Vec<_>>(),
4055                });
4056                json_result(&result)
4057            }
4058            FaultAction::Clear => {
4059                let Some(command) = params.command else {
4060                    return missing_param("command", "clear");
4061                };
4062                let removed = self.state.fault_registry.clear(&command);
4063                json_result(&serde_json::json!({
4064                    "removed": removed,
4065                    "command": command,
4066                }))
4067            }
4068            FaultAction::ClearAll => {
4069                let removed = self.state.fault_registry.clear_all();
4070                json_result(&serde_json::json!({
4071                    "removed": removed,
4072                }))
4073            }
4074        }
4075    }
4076
4077    // ── Cross-Layer Explanation ────────────────────────────────────────────
4078
4079    #[tool(
4080        description = "Correlate recent activity across all layers into a coherent narrative. \
4081            CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
4082            + window events across the Rust backend and webview simultaneously.\n\n\
4083            Actions:\n\
4084            - `summary`: High-level activity summary for the last N seconds (default 30). \
4085              Counts IPC calls, DOM mutations, console entries, state changes (incl. network \
4086              requests), errors. With no recording active it reads the window's live event \
4087              stream (`webview_label`, default main).\n\
4088            - `last_action`: Correlate the most recent burst of events into a causal timeline \
4089              (e.g. 'IPC call → DOM update → console.log').\n\
4090            - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
4091        annotations(
4092            read_only_hint = true,
4093            destructive_hint = false,
4094            idempotent_hint = true,
4095            open_world_hint = false
4096        )
4097    )]
4098    async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
4099        if !self.state.privacy.is_tool_enabled("explain") {
4100            return tool_disabled("explain");
4101        }
4102
4103        match params.action {
4104            ExplainAction::Summary => {
4105                let secs = params.seconds.unwrap_or(30);
4106                let since = bounded::secs_ago(chrono::Utc::now(), secs);
4107                let events = self
4108                    .explain_events(since, params.webview_label.as_deref())
4109                    .await;
4110
4111                let mut ipc_count = 0u64;
4112                let mut dom_mutations = 0u64;
4113                let mut state_changes = 0u64;
4114                let mut console_count = 0u64;
4115                let mut window_events = 0u64;
4116                let mut interactions = 0u64;
4117                let mut top_commands: HashMap<String, u64> = HashMap::new();
4118                let mut errors: Vec<String> = Vec::new();
4119
4120                for event in &events {
4121                    match event {
4122                        victauri_core::AppEvent::Ipc(call) => {
4123                            ipc_count += 1;
4124                            *top_commands.entry(call.command.clone()).or_insert(0) += 1;
4125                            if let victauri_core::IpcResult::Err(e) = &call.result {
4126                                errors.push(format!("IPC {}: {e}", call.command));
4127                            }
4128                        }
4129                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
4130                            dom_mutations += u64::from(*mutation_count)
4131                        }
4132                        victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
4133                        victauri_core::AppEvent::Console { level, message, .. } => {
4134                            console_count += 1;
4135                            if level == "error" {
4136                                errors.push(format!("console.error: {message}"));
4137                            }
4138                        }
4139                        victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
4140                        victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
4141                        _ => {}
4142                    }
4143                }
4144
4145                let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
4146                sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
4147                let top: Vec<_> = sorted_cmds.iter().take(5).collect();
4148
4149                let narrative = format!(
4150                    "{ipc_count} IPC call{} in the last {secs}s{}. \
4151                     {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
4152                     {console_count} console message{}, {window_events} window event{}. {}.",
4153                    if ipc_count == 1 { "" } else { "s" },
4154                    if top.is_empty() {
4155                        String::new()
4156                    } else {
4157                        format!(
4158                            ", dominated by {}",
4159                            top.iter()
4160                                .map(|(cmd, n)| format!("{cmd} ({n}x)"))
4161                                .collect::<Vec<_>>()
4162                                .join(", ")
4163                        )
4164                    },
4165                    if dom_mutations == 1 { "" } else { "s" },
4166                    if interactions == 1 { "" } else { "s" },
4167                    if console_count == 1 { "" } else { "s" },
4168                    if window_events == 1 { "" } else { "s" },
4169                    if errors.is_empty() {
4170                        "No errors".to_string()
4171                    } else {
4172                        format!(
4173                            "{} error{}",
4174                            errors.len(),
4175                            if errors.len() == 1 { "" } else { "s" }
4176                        )
4177                    },
4178                );
4179
4180                let result = serde_json::json!({
4181                    "time_window_secs": secs,
4182                    "total_events": events.len(),
4183                    "ipc_calls": ipc_count,
4184                    "dom_mutations": dom_mutations,
4185                    "state_changes": state_changes,
4186                    "console_messages": console_count,
4187                    "window_events": window_events,
4188                    "interactions": interactions,
4189                    "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
4190                        serde_json::json!({"command": cmd, "count": n})
4191                    }).collect::<Vec<_>>(),
4192                    "errors": errors,
4193                    "narrative": narrative,
4194                });
4195                json_result(&result)
4196            }
4197            ExplainAction::LastAction => {
4198                let secs = params.seconds.unwrap_or(5);
4199                let since = bounded::secs_ago(chrono::Utc::now(), secs);
4200                let events = self
4201                    .explain_events(since, params.webview_label.as_deref())
4202                    .await;
4203
4204                let timeline: Vec<serde_json::Value> = events
4205                    .iter()
4206                    .filter(|e| !e.is_internal())
4207                    .map(|event| match event {
4208                        victauri_core::AppEvent::Ipc(call) => serde_json::json!({
4209                            "time": call.timestamp.to_rfc3339_opts(
4210                                chrono::SecondsFormat::Millis, true
4211                            ),
4212                            "type": "ipc",
4213                            "detail": format!(
4214                                "{} {} ({}ms)",
4215                                call.command,
4216                                call.result,
4217                                call.duration_ms.unwrap_or(0)
4218                            ),
4219                        }),
4220                        victauri_core::AppEvent::DomMutation {
4221                            timestamp,
4222                            mutation_count,
4223                            webview_label,
4224                            ..
4225                        } => serde_json::json!({
4226                            "time": timestamp.to_rfc3339_opts(
4227                                chrono::SecondsFormat::Millis, true
4228                            ),
4229                            "type": "dom_mutation",
4230                            "detail": format!(
4231                                "{mutation_count} element{} updated in {webview_label}",
4232                                if *mutation_count == 1 { "" } else { "s" }
4233                            ),
4234                        }),
4235                        victauri_core::AppEvent::DomInteraction {
4236                            timestamp,
4237                            action,
4238                            selector,
4239                            ..
4240                        } => serde_json::json!({
4241                            "time": timestamp.to_rfc3339_opts(
4242                                chrono::SecondsFormat::Millis, true
4243                            ),
4244                            "type": "interaction",
4245                            "detail": format!("{action} on {selector}"),
4246                        }),
4247                        victauri_core::AppEvent::StateChange {
4248                            timestamp,
4249                            key,
4250                            caused_by,
4251                            ..
4252                        } => serde_json::json!({
4253                            "time": timestamp.to_rfc3339_opts(
4254                                chrono::SecondsFormat::Millis, true
4255                            ),
4256                            "type": "state_change",
4257                            "detail": format!(
4258                                "{key} changed{}",
4259                                caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
4260                            ),
4261                        }),
4262                        victauri_core::AppEvent::Console {
4263                            timestamp,
4264                            level,
4265                            message,
4266                            ..
4267                        } => serde_json::json!({
4268                            "time": timestamp.to_rfc3339_opts(
4269                                chrono::SecondsFormat::Millis, true
4270                            ),
4271                            "type": "console",
4272                            "detail": format!("console.{level}: {message}"),
4273                        }),
4274                        victauri_core::AppEvent::WindowEvent {
4275                            timestamp,
4276                            label,
4277                            event,
4278                            ..
4279                        } => serde_json::json!({
4280                            "time": timestamp.to_rfc3339_opts(
4281                                chrono::SecondsFormat::Millis, true
4282                            ),
4283                            "type": "window_event",
4284                            "detail": format!("{event} on window '{label}'"),
4285                        }),
4286                        _ => serde_json::json!({
4287                            "time": event.timestamp().to_rfc3339_opts(
4288                                chrono::SecondsFormat::Millis, true
4289                            ),
4290                            "type": "other",
4291                            "detail": "unknown event type",
4292                        }),
4293                    })
4294                    .collect();
4295
4296                let narrative = if timeline.is_empty() {
4297                    format!("No activity in the last {secs}s.")
4298                } else {
4299                    let parts: Vec<String> = timeline
4300                        .iter()
4301                        .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
4302                        .map(String::from)
4303                        .collect();
4304                    parts.join(" → ")
4305                };
4306
4307                let result = serde_json::json!({
4308                    "time_window_secs": secs,
4309                    "event_count": timeline.len(),
4310                    "timeline": timeline,
4311                    "narrative": narrative,
4312                });
4313                json_result(&result)
4314            }
4315            ExplainAction::Diff => {
4316                let secs = params.seconds.unwrap_or(10);
4317                let since = bounded::secs_ago(chrono::Utc::now(), secs);
4318                let events = self
4319                    .explain_events(since, params.webview_label.as_deref())
4320                    .await;
4321
4322                let mut ipc_commands: Vec<String> = Vec::new();
4323                let mut dom_changes = 0u64;
4324                let mut error_count = 0u64;
4325                let mut interaction_count = 0u64;
4326                let mut console_messages = 0u64;
4327
4328                for event in &events {
4329                    if event.is_internal() {
4330                        continue;
4331                    }
4332                    match event {
4333                        victauri_core::AppEvent::Ipc(call) => {
4334                            ipc_commands.push(call.command.clone());
4335                            if matches!(call.result, victauri_core::IpcResult::Err(_)) {
4336                                error_count += 1;
4337                            }
4338                        }
4339                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
4340                            dom_changes += u64::from(*mutation_count)
4341                        }
4342                        victauri_core::AppEvent::DomInteraction { .. } => {
4343                            interaction_count += 1;
4344                        }
4345                        victauri_core::AppEvent::Console { level, .. } => {
4346                            console_messages += 1;
4347                            if level == "error" {
4348                                error_count += 1;
4349                            }
4350                        }
4351                        _ => {}
4352                    }
4353                }
4354
4355                // Every call counts; each command is listed once, in first-call order (a
4356                // consecutive-only `dedup` used to undercount the calls and still list a
4357                // command once per non-adjacent run).
4358                let ipc_calls_made = ipc_commands.len();
4359                let mut seen = HashSet::new();
4360                ipc_commands.retain(|c| seen.insert(c.clone()));
4361
4362                let result = serde_json::json!({
4363                    "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
4364                    "time_window_secs": secs,
4365                    "total_events": events.len(),
4366                    "ipc_calls_made": ipc_calls_made,
4367                    "unique_commands": ipc_commands,
4368                    "dom_elements_changed": dom_changes,
4369                    "interactions": interaction_count,
4370                    "console_messages": console_messages,
4371                    "errors": error_count,
4372                });
4373                json_result(&result)
4374            }
4375        }
4376    }
4377}
4378
4379impl VictauriMcpHandler {
4380    /// Create a new handler backed by the given state and webview bridge.
4381    pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
4382        Self {
4383            state,
4384            bridge,
4385            subscriptions: Arc::new(Mutex::new(HashSet::new())),
4386            bridge_checked: Arc::new(AtomicBool::new(false)),
4387            timed_out_labels: Arc::new(Mutex::new(HashSet::new())),
4388            probe_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_PROBES)),
4389            file_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_FILE_READS)),
4390            probe_timeout: PROBE_TIMEOUT,
4391            file_read_timeout: READ_APP_FILE_TIMEOUT,
4392        }
4393    }
4394
4395    pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
4396        self.state.privacy.is_tool_enabled(name)
4397    }
4398
4399    pub(crate) async fn execute_tool(
4400        &self,
4401        name: &str,
4402        args: serde_json::Value,
4403    ) -> Result<CallToolResult, rest::ToolCallError> {
4404        // Centralized authorization: resolve the canonical `tool.action` capability
4405        // and gate on it BEFORE dispatch, so every compound action is checked
4406        // uniformly (not just the ones whose handler remembers to). See `authz`.
4407        // A disabled tool reports "disabled" whatever its arguments look like.
4408        if self.state.privacy.disabled_tools.contains(name) {
4409            return Ok(tool_disabled(name));
4410        }
4411        let capability =
4412            authz::resolve_capability(name, &args).map_err(rest::ToolCallError::InvalidParams)?;
4413        if !self.state.privacy.is_call_allowed(name, &capability) {
4414            return Ok(tool_disabled(&capability));
4415        }
4416        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
4417        let start = std::time::Instant::now();
4418        tracing::debug!(tool = %name, "REST tool invocation started");
4419
4420        // A panicking handler becomes an error RESULT; without this boundary it unwound into
4421        // hyper's connection task and the client saw a reset connection.
4422        let result = match bounded::CatchUnwind::new(self.dispatch_tool(name, args)).await {
4423            Ok(dispatched) => dispatched?,
4424            Err(panic) => {
4425                tracing::error!(tool = %name, "tool handler panicked: {panic}");
4426                tool_panicked(name, &panic)
4427            }
4428        };
4429
4430        let elapsed = start.elapsed();
4431        tracing::debug!(
4432            tool = %name,
4433            elapsed_ms = elapsed.as_millis() as u64,
4434            "REST tool invocation completed"
4435        );
4436
4437        if self.state.privacy.redaction_enabled {
4438            Ok(Self::redact_result(result, &self.state.privacy))
4439        } else {
4440            Ok(result)
4441        }
4442    }
4443
4444    /// Route one already-authorized REST call to its tool handler.
4445    async fn dispatch_tool(
4446        &self,
4447        name: &str,
4448        args: serde_json::Value,
4449    ) -> Result<CallToolResult, rest::ToolCallError> {
4450        let result = match name {
4451            "eval_js" => {
4452                let p: EvalJsParams = Self::parse_args(args)?;
4453                self.eval_js(Parameters(p)).await
4454            }
4455            "dom_snapshot" => {
4456                let p: SnapshotParams = Self::parse_args(args)?;
4457                self.dom_snapshot(Parameters(p)).await
4458            }
4459            "find_elements" => {
4460                let p: FindElementsParams = Self::parse_args(args)?;
4461                self.find_elements(Parameters(p)).await
4462            }
4463            "invoke_command" => {
4464                let p: InvokeCommandParams = Self::parse_args(args)?;
4465                self.invoke_command(Parameters(p)).await
4466            }
4467            "screenshot" => {
4468                let p: ScreenshotParams = Self::parse_args(args)?;
4469                self.screenshot(Parameters(p)).await
4470            }
4471            "verify_state" => {
4472                let p: VerifyStateParams = Self::parse_args(args)?;
4473                self.verify_state(Parameters(p)).await
4474            }
4475            "detect_ghost_commands" => {
4476                let p: GhostCommandParams = Self::parse_args(args)?;
4477                self.detect_ghost_commands(Parameters(p)).await
4478            }
4479            "check_ipc_integrity" => {
4480                let p: IpcIntegrityParams = Self::parse_args(args)?;
4481                self.check_ipc_integrity(Parameters(p)).await
4482            }
4483            "wait_for" => {
4484                let p: WaitForParams = Self::parse_args(args)?;
4485                self.wait_for(Parameters(p)).await
4486            }
4487            "assert_semantic" => {
4488                let p: SemanticAssertParams = Self::parse_args(args)?;
4489                self.assert_semantic(Parameters(p)).await
4490            }
4491            "resolve_command" => {
4492                let p: ResolveCommandParams = Self::parse_args(args)?;
4493                self.resolve_command(Parameters(p)).await
4494            }
4495            "get_registry" => {
4496                let p: RegistryParams = Self::parse_args(args)?;
4497                self.get_registry(Parameters(p)).await
4498            }
4499            "app_state" => {
4500                let p: AppStateParams = Self::parse_args(args)?;
4501                self.app_state(Parameters(p)).await
4502            }
4503            "get_memory_stats" => self.get_memory_stats().await,
4504            "get_plugin_info" => self.get_plugin_info().await,
4505            "get_diagnostics" => {
4506                let p: DiagnosticsParams = Self::parse_args(args)?;
4507                self.get_diagnostics(Parameters(p)).await
4508            }
4509            "app_info" => self.app_info().await,
4510            "list_app_dir" => {
4511                let p: ListAppDirParams = Self::parse_args(args)?;
4512                self.list_app_dir(Parameters(p)).await
4513            }
4514            "read_app_file" => {
4515                let p: ReadAppFileParams = Self::parse_args(args)?;
4516                self.read_app_file(Parameters(p)).await
4517            }
4518            "query_db" => {
4519                let p: QueryDbParams = Self::parse_args(args)?;
4520                self.query_db(Parameters(p)).await
4521            }
4522            "interact" => {
4523                let p: InteractParams = Self::parse_args(args)?;
4524                self.interact(Parameters(p)).await
4525            }
4526            "input" => {
4527                let p: InputParams = Self::parse_args(args)?;
4528                self.input(Parameters(p)).await
4529            }
4530            "window" => {
4531                let p: WindowParams = Self::parse_args(args)?;
4532                self.window(Parameters(p)).await
4533            }
4534            "storage" => {
4535                let p: StorageParams = Self::parse_args(args)?;
4536                self.storage(Parameters(p)).await
4537            }
4538            "navigate" => {
4539                let p: NavigateParams = Self::parse_args(args)?;
4540                self.navigate(Parameters(p)).await
4541            }
4542            "recording" => {
4543                let p: RecordingParams = Self::parse_args(args)?;
4544                self.recording(Parameters(p)).await
4545            }
4546            "inspect" => {
4547                let p: InspectParams = Self::parse_args(args)?;
4548                self.inspect(Parameters(p)).await
4549            }
4550            "css" => {
4551                let p: CssParams = Self::parse_args(args)?;
4552                self.css(Parameters(p)).await
4553            }
4554            "route" => {
4555                let p: RouteParams = Self::parse_args(args)?;
4556                self.route(Parameters(p)).await
4557            }
4558            "trace" => {
4559                let p: TraceParams = Self::parse_args(args)?;
4560                self.trace(Parameters(p)).await
4561            }
4562            "animation" => {
4563                let p: AnimationParams = Self::parse_args(args)?;
4564                self.animation(Parameters(p)).await
4565            }
4566            "logs" => {
4567                let p: LogsParams = Self::parse_args(args)?;
4568                self.logs(Parameters(p)).await
4569            }
4570            "introspect" => {
4571                let p: IntrospectParams = Self::parse_args(args)?;
4572                self.introspect(Parameters(p)).await
4573            }
4574            "fault" => {
4575                let p: FaultParams = Self::parse_args(args)?;
4576                self.fault(Parameters(p)).await
4577            }
4578            "explain" => {
4579                let p: ExplainParams = Self::parse_args(args)?;
4580                self.explain(Parameters(p)).await
4581            }
4582            _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
4583        };
4584        Ok(result)
4585    }
4586
4587    fn parse_args<T: serde::de::DeserializeOwned>(
4588        args: serde_json::Value,
4589    ) -> Result<T, rest::ToolCallError> {
4590        serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
4591    }
4592
4593    fn redact_result(
4594        mut result: CallToolResult,
4595        privacy: &crate::privacy::PrivacyConfig,
4596    ) -> CallToolResult {
4597        for item in &mut result.content {
4598            if let ContentBlock::Text(tc) = item {
4599                tc.text = privacy.redact_output(&tc.text);
4600            }
4601        }
4602        result
4603    }
4604
4605    /// Read at most `max_bytes` (+1, to detect truncation) of the regular file at `path` on
4606    /// the blocking pool, within [`READ_APP_FILE_TIMEOUT`] and one of the
4607    /// [`MAX_CONCURRENT_FILE_READS`] slots. A read that blocks (a FIFO or device swapped in
4608    /// after the handler's checks) returns at the deadline; its thread keeps the slot until
4609    /// it finishes, so such reads cannot pile up.
4610    async fn read_regular_file_bounded(
4611        &self,
4612        path: std::path::PathBuf,
4613        max_bytes: usize,
4614    ) -> Result<(Vec<u8>, usize, Option<u64>), String> {
4615        let Ok(slot) = Arc::clone(&self.file_slots).try_acquire_owned() else {
4616            return Err(format!(
4617                "file reads are busy ({MAX_CONCURRENT_FILE_READS} still running — a read \
4618                 blocked on a pipe or device keeps running past its timeout). Retry shortly."
4619            ));
4620        };
4621        bounded::run_blocking_bounded(None, "file read", self.file_read_timeout, move || {
4622            let _slot = slot;
4623            read_regular_file(&path, max_bytes)
4624        })
4625        .await
4626    }
4627
4628    fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
4629        match dir.unwrap_or(AppDir::Data) {
4630            AppDir::Data => self.bridge.app_data_dir(),
4631            AppDir::Config => self.bridge.app_config_dir(),
4632            AppDir::Log => self.bridge.app_log_dir(),
4633            AppDir::LocalData => self.bridge.app_local_data_dir(),
4634        }
4635    }
4636
4637    /// Lexical (pre-existence) traversal guard for a user-supplied sub-path.
4638    ///
4639    /// Rejects absolute paths and any component that is `..` BEFORE the path is
4640    /// canonicalized. This is necessary because [`Self::safe_within`] relies on
4641    /// `canonicalize`, which errors on non-existent paths — so a traversal
4642    /// attempt against a missing target would otherwise be reported as
4643    /// "not found" (an info-leak oracle) rather than as traversal.
4644    fn lexical_safe(sub: &std::path::Path) -> Result<(), String> {
4645        use std::path::Component;
4646        if sub.is_absolute() {
4647            return Err("path traversal not allowed: absolute paths are rejected".to_string());
4648        }
4649        for component in sub.components() {
4650            match component {
4651                Component::ParentDir => {
4652                    return Err("path traversal not allowed: '..' is rejected".to_string());
4653                }
4654                Component::Prefix(_) | Component::RootDir => {
4655                    return Err(
4656                        "path traversal not allowed: absolute paths are rejected".to_string()
4657                    );
4658                }
4659                Component::CurDir | Component::Normal(_) => {}
4660            }
4661        }
4662        Ok(())
4663    }
4664
4665    fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
4666        let canon_base = std::fs::canonicalize(base)
4667            .map_err(|e| format!("cannot resolve base directory: {e}"))?;
4668        let canon_target = std::fs::canonicalize(target)
4669            .map_err(|e| format!("cannot resolve target path: {e}"))?;
4670        if !canon_target.starts_with(&canon_base) {
4671            return Err("path traversal not allowed".to_string());
4672        }
4673        Ok(())
4674    }
4675
4676    #[cfg(feature = "sqlite")]
4677    fn resolve_existing_db_path(
4678        roots: &[std::path::PathBuf],
4679        requested: &str,
4680    ) -> Result<std::path::PathBuf, String> {
4681        let candidate = std::path::Path::new(requested);
4682        // One answer for "missing" and "resolves outside every root" (a symlink inside a root
4683        // pointing elsewhere): distinct answers told the caller whether an arbitrary path
4684        // exists on disk.
4685        let not_found = || {
4686            format!(
4687                "database not found (or it resolves outside the allowed directories): {requested}"
4688            )
4689        };
4690        if candidate.is_absolute() {
4691            // `root/link/../y` normalizes lexically to `root/y`, but the OS resolves `..` AFTER
4692            // following `link` — so the existence check probed a path outside every root.
4693            if candidate
4694                .components()
4695                .any(|c| matches!(c, std::path::Component::ParentDir))
4696            {
4697                return Err("path traversal not allowed: '..' is rejected".to_string());
4698            }
4699            // Decide containment LEXICALLY first, before touching the requested path: checking
4700            // existence first answered "does X exist?" for any path on disk ("not found" vs
4701            // "not within an allowed directory"). The canonical check below still catches
4702            // symlink escapes for paths that are lexically inside a root.
4703            let normalized = lexically_normalize(candidate);
4704            let lexically_inside = roots.iter().any(|root| {
4705                normalized.starts_with(lexically_normalize(root))
4706                    || std::fs::canonicalize(root)
4707                        .is_ok_and(|canon_root| normalized.starts_with(canon_root))
4708            });
4709            if !lexically_inside {
4710                return Err(format!(
4711                    "absolute path '{requested}' is not within an allowed directory; \
4712                     register its parent via VictauriBuilder::db_search_paths"
4713                ));
4714            }
4715            if candidate.exists()
4716                && roots
4717                    .iter()
4718                    .any(|root| Self::safe_within(root, candidate).is_ok())
4719            {
4720                // Open the CANONICAL validated path, not the caller's literal absolute path,
4721                // so the DB is opened at exactly the containment-approved location — symmetric
4722                // with the relative branch below and closing the validate-canonical/open-lexical
4723                // TOCTOU on this branch (a same-privilege symlink swap between canonicalize and
4724                // open is the unavoidable residual, documented in security.md).
4725                let canonical = std::fs::canonicalize(candidate)
4726                    .map_err(|e| format!("cannot resolve database path: {e}"))?;
4727                return Ok(canonical);
4728            }
4729            return Err(not_found());
4730        }
4731
4732        Self::lexical_safe(candidate)?;
4733        for root in roots {
4734            let resolved = root.join(candidate);
4735            // A match that escapes its root is treated exactly like a miss (see `not_found`).
4736            if resolved.exists() && Self::safe_within(root, &resolved).is_ok() {
4737                // Open the CANONICAL validated path, not the lexical join, so the DB is opened
4738                // at exactly the path containment approved (closes the trivial validate-lexical
4739                // vs open-lexical TOCTOU; a same-privilege local symlink swap between
4740                // canonicalize and open is the unavoidable residual, documented in security.md).
4741                let canonical = std::fs::canonicalize(&resolved)
4742                    .map_err(|e| format!("cannot resolve database path: {e}"))?;
4743                return Ok(canonical);
4744            }
4745        }
4746
4747        let roots = roots
4748            .iter()
4749            .map(|root| root.display().to_string())
4750            .collect::<Vec<_>>()
4751            .join(", ");
4752        Err(format!("{} (searched: {roots})", not_found()))
4753    }
4754
4755    /// Events for `explain` since `since`. While a recording is active the background drain
4756    /// keeps `event_log` current, so read it. Otherwise nothing drains (idle draining was the
4757    /// 0.8.x host-crash amplifier), so read the bridge's own event stream ON DEMAND — without
4758    /// writing it into `event_log`, so a later recording never double-counts it. Before this,
4759    /// `explain` silently reported "0 IPC calls, 0 DOM mutations…" for any app not recording.
4760    async fn explain_events(
4761        &self,
4762        since: chrono::DateTime<chrono::Utc>,
4763        webview_label: Option<&str>,
4764    ) -> Vec<victauri_core::AppEvent> {
4765        if self.state.recorder.is_recording() {
4766            return self.state.event_log.since(since);
4767        }
4768        let code = format!(
4769            "return window.__VICTAURI__?.getEventStream({})",
4770            since.timestamp_millis()
4771        );
4772        let Ok(raw) = self.eval_with_return(&code, webview_label).await else {
4773            return self.state.event_log.since(since);
4774        };
4775        let label = webview_label.unwrap_or("main");
4776        let mut events: Vec<victauri_core::AppEvent> =
4777            page_json::parse_page_json::<Vec<serde_json::Value>>(&raw)
4778                .unwrap_or_default()
4779                .iter()
4780                .filter_map(|ev| crate::mcp::server::parse_bridge_event_from(ev, label))
4781                .filter(|ev| !ev.is_internal() && ev.timestamp() >= since)
4782                .collect();
4783        events.sort_by_key(victauri_core::AppEvent::timestamp);
4784        events
4785    }
4786
4787    /// Resolve the EXACT window a native capture (`screenshot`, `trace`) should target, and
4788    /// require it to be visible. A native capture reads the on-screen surface; a hidden window
4789    /// has none, so the OS path (`PrintWindow` / `CGWindowListCreateImage`) silently returns
4790    /// stale, empty, or ANOTHER window's pixels with no error. Two failure modes are closed:
4791    ///   1. (live-4DA, 2026-06-16) an explicitly-requested hidden window (label:"briefing")
4792    ///      returned the MAIN window's pixels.
4793    ///   2. (GPT audit, P2) with no label, `find_window(None)` prefers "main" UNCONDITIONALLY,
4794    ///      so an app that hides main but leaves a secondary window visible captured hidden
4795    ///      main. `find_window` is left alone — other callers (e.g. eval) may legitimately
4796    ///      target a hidden window — so capture tools resolve their own VISIBLE target.
4797    ///
4798    /// Acknowledged residual: resolve -> handle -> capture are separate calls, so a window
4799    /// hidden in the gap is still TOCTOU; the worst case is a wrong image, not a security
4800    /// boundary.
4801    fn resolve_visible_capture_target(&self, label: Option<&str>) -> Result<String, String> {
4802        let states = self
4803            .bridge
4804            .try_get_window_states(None)
4805            .map_err(|e| ui_busy(&e))?;
4806        if let Some(label) = label {
4807            // An explicit label that the enumerator reports hidden is rejected; visible or
4808            // unknown labels fall through (an unknown one lets get_native_handle produce the
4809            // canonical "window not found" rather than inventing an error here).
4810            if states.iter().any(|s| s.label == label && !s.visible) {
4811                return Err(format!(
4812                    "window '{label}' is not visible — a native screenshot captures the \
4813                     on-screen surface, and a hidden window has none (the OS capture would \
4814                     return stale or another window's pixels). Show it first \
4815                     (window action=manage manage_action=show label={label}), then capture."
4816                ));
4817            }
4818            return Ok(label.to_string());
4819        }
4820        // No label: prefer a visible "main", else the first visible window. NEVER silently
4821        // fall back to a hidden window (the P2 bug) — error instead.
4822        states
4823            .iter()
4824            .find(|s| s.label == "main" && s.visible)
4825            .or_else(|| states.iter().find(|s| s.visible))
4826            .map(|st| st.label.clone())
4827            .ok_or_else(|| {
4828                "no visible window to capture — every window is hidden (or the UI is \
4829                 not responding). Show one first (window action=manage \
4830                 manage_action=show label=<label>), then capture."
4831                    .to_string()
4832            })
4833    }
4834
4835    /// Whether `target` exists, after refusing it if it resolves outside `base`.
4836    ///
4837    /// For a missing target, containment is decided on its deepest EXISTING ancestor, so a
4838    /// path routed through a symlink out of `base` is refused whether or not its final
4839    /// component exists ("missing" vs "outside" would otherwise reveal whether an arbitrary
4840    /// outside path exists).
4841    fn contained_or_missing(
4842        base: &std::path::Path,
4843        target: &std::path::Path,
4844    ) -> Result<bool, String> {
4845        // A missing base holds nothing (and no symlink that could lead out of it).
4846        let Ok(canon_base) = std::fs::canonicalize(base) else {
4847            return Ok(false);
4848        };
4849        let exists = target.exists();
4850        let probe = if exists {
4851            Some(target)
4852        } else {
4853            target.ancestors().skip(1).find(|a| a.exists())
4854        };
4855        if let Some(probe) = probe {
4856            let canonical = std::fs::canonicalize(probe)
4857                .map_err(|e| format!("cannot resolve target path: {e}"))?;
4858            if !canonical.starts_with(&canon_base) {
4859                return Err("path traversal not allowed".to_string());
4860            }
4861        }
4862        Ok(exists)
4863    }
4864
4865    fn matches_glob(name: &str, pattern: &str) -> bool {
4866        if pattern == "*" {
4867            return true;
4868        }
4869        if let Some(suffix) = pattern.strip_prefix("*.") {
4870            return name.ends_with(&format!(".{suffix}"));
4871        }
4872        if let Some(prefix) = pattern.strip_suffix("*") {
4873            return name.starts_with(prefix);
4874        }
4875        name == pattern
4876    }
4877
4878    /// Probe every window's JS bridge and report which are introspectable. A
4879    /// visible window that fails to respond almost always lacks the
4880    /// `victauri:default` capability — Tauri's permission ACL silently blocks
4881    /// the bridge's callback IPC, so eval/dom/animation tools see nothing. This
4882    /// turns that silent dead-end into an actionable, up-front diagnosis.
4883    async fn window_introspectability(&self) -> CallToolResult {
4884        let labels = match self.bridge.try_list_window_labels() {
4885            Ok(labels) => labels,
4886            Err(e) => return tool_error(ui_busy(&e)),
4887        };
4888        let states = match self.bridge.try_get_window_states(None) {
4889            Ok(states) => states,
4890            Err(e) => return tool_error(ui_busy(&e)),
4891        };
4892        let mut report = Vec::with_capacity(labels.len());
4893        let mut blind = 0usize;
4894        for label in &labels {
4895            let visible = states.iter().find(|s| &s.label == label).map(|s| s.visible);
4896            let introspectable = self.probe_bridge(Some(label), None, None).await.is_ok();
4897            if !introspectable {
4898                blind += 1;
4899            }
4900            let note = if introspectable {
4901                "ok — Victauri JS bridge is responding".to_string()
4902            } else if visible == Some(true) {
4903                format!(
4904                    "NOT introspectable although the window is visible — almost certainly missing \
4905                     the Victauri capability. Add \"victauri:default\" to the capability file \
4906                     (src-tauri/capabilities/*.json) whose \"windows\" list includes \"{label}\", \
4907                     then rebuild. Capabilities are baked at compile time, so a rebuild is required."
4908                )
4909            } else {
4910                "NOT introspectable (window is hidden and/or has no bridge) — show the window to \
4911                 confirm, and ensure its capability includes \"victauri:default\", then rebuild."
4912                    .to_string()
4913            };
4914            report.push(serde_json::json!({
4915                "label": label,
4916                "visible": visible,
4917                "introspectable": introspectable,
4918                "note": note,
4919            }));
4920        }
4921        let hint = if blind > 0 {
4922            "Windows with introspectable:false have no working Victauri JS bridge — eval_js, \
4923             dom_snapshot, animation, find_elements, etc. cannot see them. The usual cause is a \
4924             missing \"victauri:default\" capability for that window: Tauri's per-window permission \
4925             ACL silently blocks the bridge's callback IPC. This capability is required per window, \
4926             not just for the main window. (Note: probing a blind window takes ~2s each.)"
4927        } else {
4928            "All windows are introspectable."
4929        };
4930        json_result(&serde_json::json!({
4931            "windows": report,
4932            "introspectable_count": labels.len().saturating_sub(blind),
4933            "blind_count": blind,
4934            "hint": hint,
4935        }))
4936    }
4937
4938    /// Focus `ref_id` before trusted (OS-level) keystrokes and confirm focus LANDED on it —
4939    /// the keys go to whatever holds focus, so an element that exists but did not take focus
4940    /// (not focusable, inert, or a focus handler moved focus on) must stop the input.
4941    async fn focus_for_trusted_input(
4942        &self,
4943        ref_id: &str,
4944        webview_label: Option<&str>,
4945    ) -> Result<(), CallToolResult> {
4946        let raw = self
4947            .eval_with_return(&trusted_focus_probe_js(ref_id), webview_label)
4948            .await
4949            .map_err(|e| {
4950                tool_error(format!(
4951                    "could not focus {ref_id} before sending OS input: {e} — no keys were sent"
4952                ))
4953            })?;
4954        let answer = serde_json::from_str::<serde_json::Value>(&raw).unwrap_or_default();
4955        if answer.get("focused") == Some(&serde_json::Value::Bool(true)) {
4956            return Ok(());
4957        }
4958        let why = if answer.get("found") == Some(&serde_json::Value::Bool(true)) {
4959            "focus did not land on it (not focusable, inert, or a focus handler moved focus \
4960             elsewhere)"
4961        } else {
4962            "ref not found"
4963        };
4964        Err(tool_error_with_hint(
4965            format!(
4966                "ref not found or not focusable: {ref_id}: {why} — no keys were sent (they \
4967                 would have gone to whatever holds focus)"
4968            ),
4969            RecoveryHint::CheckInput,
4970        ))
4971    }
4972
4973    /// The window's IPC capture status (`getIpcCaptureStatus`): which Tauri IPC transport the
4974    /// page uses and how completely the bridge captures it. Best effort — `null` when it cannot
4975    /// be read (an older bridge, an unreachable window) — so it never fails the tool it
4976    /// annotates. Every IPC-log-derived report carries it, so a report built from a partial
4977    /// IPC picture says so instead of looking complete.
4978    async fn ipc_capture_status(&self, webview_label: Option<&str>) -> serde_json::Value {
4979        match self
4980            .eval_with_return_timeout(
4981                IPC_CAPTURE_STATUS_JS,
4982                webview_label,
4983                std::time::Duration::from_secs(5),
4984            )
4985            .await
4986        {
4987            Ok(raw) => page_json::parse_page_json::<serde_json::Value>(&raw)
4988                .ok()
4989                .filter(serde_json::Value::is_object)
4990                .unwrap_or(serde_json::Value::Null),
4991            Err(_) => serde_json::Value::Null,
4992        }
4993    }
4994
4995    async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
4996        match self.eval_with_return(code, webview_label).await {
4997            Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
4998            Err(e) => tool_error(e),
4999        }
5000    }
5001
5002    /// Run a page ACTION (`interact`, `input`, `inspect`, `route add`) and report a failure the
5003    /// page returns — `{ok: false, error, hint}` or `{error}` — as a tool error carrying the
5004    /// page's message and recovery hint. These used to come back as a SUCCESS result, so a
5005    /// refused click (covered, disabled, ref not found) read as done unless the caller parsed
5006    /// the body (R5B-ISERR1).
5007    async fn eval_page_action(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
5008        match self.eval_with_return(code, webview_label).await {
5009            Ok(result) => match page_action_error(&result) {
5010                Some(message) => tool_error(message),
5011                None => CallToolResult::success(vec![ContentBlock::text(result)]),
5012            },
5013            Err(e) => tool_error(e),
5014        }
5015    }
5016
5017    async fn eval_with_return(
5018        &self,
5019        code: &str,
5020        webview_label: Option<&str>,
5021    ) -> Result<String, String> {
5022        self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
5023            .await
5024    }
5025
5026    /// Atomically reserve a pending-eval slot under a SINGLE lock: reject if the map is
5027    /// already at the concurrency ceiling, otherwise insert. This makes `MAX_PENDING_EVALS`
5028    /// a TRUE hard ceiling — a separate check-then-insert races (concurrent callers all pass
5029    /// a stale check, then each inserts, blowing past the cap). On a saturated map it also
5030    /// fails fast (before any eval is injected) with the real "too many concurrent" cause
5031    /// rather than letting a probe burn its full timeout. The slot is released when the
5032    /// returned guard drops — on every exit, including the caller's future being dropped.
5033    async fn reserve_pending(
5034        &self,
5035        id: &str,
5036        tx: tokio::sync::oneshot::Sender<String>,
5037    ) -> Result<crate::PendingSlot, String> {
5038        let mut pending = self.state.pending_evals.lock().await;
5039        if pending.len() >= MAX_PENDING_EVALS {
5040            return Err(format!(
5041                "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
5042            ));
5043        }
5044        Ok(crate::PendingSlot::insert(
5045            &self.state.pending_evals,
5046            &mut pending,
5047            id.to_string(),
5048            tx,
5049        ))
5050    }
5051
5052    /// Liveness probe. On success, returns the nonce of the page that answered (`None` for a
5053    /// page without the Victauri bridge).
5054    /// Within an agent call, `deadline` bounds the probe's main-thread DISPATCH by the time that
5055    /// call has left (none is started once it has passed). The wait for the answer is the
5056    /// probe's own [`PROBE_ANSWER_WINDOW`] unless `answer_by` cuts it shorter — only for callers
5057    /// that treat a failed probe as "no evidence" (`page_replaced`): a cut-short probe cannot
5058    /// tell a dead page from a busy one, so the probe that decides "bridge not responding" always
5059    /// gets its full window (calling a healthy page dead sends the agent hunting for a missing
5060    /// capability).
5061    async fn probe_bridge(
5062        &self,
5063        webview_label: Option<&str>,
5064        deadline: Option<tokio::time::Instant>,
5065        answer_by: Option<tokio::time::Instant>,
5066    ) -> Result<Option<String>, String> {
5067        if deadline.is_some_and(|d| tokio::time::Instant::now() >= d) {
5068            return Err(
5069                "the call's time ran out before the page could be probed (the app's UI thread is \
5070                 busy)"
5071                    .to_string(),
5072            );
5073        }
5074        let id = uuid::Uuid::new_v4().to_string();
5075        let (tx, rx) = tokio::sync::oneshot::channel();
5076        let slot = self.reserve_pending(&id, tx).await?;
5077        slot.bind_window(webview_label);
5078        let probe = crate::js_bridge::eval_probe_script(&id);
5079        let injected = {
5080            let _budget = deadline.map(|d| crate::bridge::CallBudget::until(d.into_std()));
5081            self.bridge.eval_webview(webview_label, &probe)
5082        };
5083        if let Err(e) = injected {
5084            return Err(format!("eval injection failed: {e}"));
5085        }
5086        let wait = answer_by.map_or(PROBE_ANSWER_WINDOW, |d| {
5087            d.saturating_duration_since(tokio::time::Instant::now())
5088                .min(PROBE_ANSWER_WINDOW)
5089        });
5090        if let Ok(Ok(raw)) = tokio::time::timeout(wait, rx).await {
5091            Ok(crate::js_bridge::probe_answer_nonce(&raw))
5092        } else {
5093            let label = webview_label.unwrap_or("default");
5094            Err(format!(
5095                "bridge not responding on window '{label}' — the window may be hidden, \
5096                 missing the victauri capability, or the JS bridge is not loaded (e.g. the page \
5097                 failed to load: a dev-server connection-refused or blank error page has no JS \
5098                 bridge — check the window with the `screenshot` tool, which works regardless)"
5099            ))
5100        }
5101    }
5102
5103    /// Whether window `label` has shown a page other than the one an eval was armed in since
5104    /// ready-signal `seen` — and if so, the error to report. A ready signal carrying the armed
5105    /// nonce is the eval's own page announcing itself late (under load that can take seconds);
5106    /// any other is confirmed by asking the page for its CURRENT nonce, because page script can
5107    /// send a ready signal itself and must not be able to abort the agent's calls with it.
5108    ///
5109    /// Only POSITIVE evidence aborts: the page answering the probe with a nonce other than the
5110    /// armed one (its bridge's nonce is frozen, so it cannot change without a new page — and a
5111    /// page answering with no nonce where one was armed has lost the bridge that armed the
5112    /// eval). A probe that fails (a busy UI thread, a full slot map) proves nothing, so the
5113    /// eval keeps waiting — its own timeout still bounds it — and the signal is re-checked on
5114    /// the next watch tick. Aborting on a failed probe let page script (a forged signal while
5115    /// the UI is busy) cut an agent's call short and invited a retry that ran code twice.
5116    /// With no armed nonce (a page without the Victauri bridge) only a page that now HAS a
5117    /// nonce is evidence of a change.
5118    ///
5119    /// Bounded by the call's `deadline`: once it has passed, no probe is started (the signal is
5120    /// left unconfirmed and the eval's own deadline ends the call).
5121    async fn page_replaced(
5122        &self,
5123        label: Option<&str>,
5124        seen: &mut u64,
5125        armed: Option<&str>,
5126        deadline: tokio::time::Instant,
5127    ) -> Option<String> {
5128        let label = label?;
5129        let load = self.state.page_loads.latest(label)?;
5130        if load.seq <= *seen {
5131            return None;
5132        }
5133        if tokio::time::Instant::now() >= deadline {
5134            return None;
5135        }
5136        let previously_seen = *seen;
5137        *seen = load.seq;
5138        if armed.is_some() && load.nonce.as_deref() == armed {
5139            return None;
5140        }
5141        // A probe that cannot finish by the deadline proves nothing here (see above), so its
5142        // answer wait is cut to the time left: confirming a reload never extends the call.
5143        match self
5144            .probe_bridge(Some(label), Some(deadline), Some(deadline))
5145            .await
5146        {
5147            Ok(current) if current.as_deref() != armed => Some(format!(
5148                "window '{label}' loaded a new page (a reload or navigation) while the call was \
5149                 in flight, so no result will arrive. The code may or may not have run before \
5150                 the reload — check the app's state before re-running it."
5151            )),
5152            Ok(_) => None,
5153            Err(e) => {
5154                tracing::debug!(
5155                    window = label,
5156                    "ready signal not confirmed ({e}); the eval keeps waiting"
5157                );
5158                // Unconfirmed: look at this signal again on the next watch tick.
5159                *seen = previously_seen;
5160                None
5161            }
5162        }
5163    }
5164
5165    /// Drain every window into the active recording one last time before `recording stop`,
5166    /// within [`FINAL_FLUSH_BUDGET`] in total. Returns the windows that could not be read in time
5167    /// (their not-yet-drained events are missing from the stopped session).
5168    async fn final_recording_flush(&self) -> Vec<String> {
5169        let Ok(labels) = self.bridge.try_list_window_labels() else {
5170            return vec!["(window list unavailable: UI thread busy)".to_string()];
5171        };
5172        let mut pending = labels.clone();
5173        let flushed = tokio::time::timeout(FINAL_FLUSH_BUDGET, async {
5174            for label in &labels {
5175                if crate::mcp::server::drain_window_into_recording(&self.state, &self.bridge, label)
5176                    .await
5177                    .is_some()
5178                {
5179                    pending.retain(|l| l != label);
5180                }
5181            }
5182        })
5183        .await;
5184        if flushed.is_err() {
5185            tracing::debug!("recording stop: final flush ran out of time");
5186        }
5187        pending
5188    }
5189
5190    async fn eval_with_return_timeout(
5191        &self,
5192        code: &str,
5193        webview_label: Option<&str>,
5194        timeout: std::time::Duration,
5195    ) -> Result<String, String> {
5196        self.eval_outcome(code, webview_label, timeout)
5197            .await
5198            .map_err(|f| f.message)
5199    }
5200
5201    /// Run `code` in the webview and wait for its outcome; a failure says whether the code ran.
5202    #[allow(clippy::too_many_lines)]
5203    async fn eval_outcome(
5204        &self,
5205        code: &str,
5206        webview_label: Option<&str>,
5207        timeout: std::time::Duration,
5208    ) -> Result<String, EvalFailure> {
5209        use EvalFailureKind::{Aborted, NotSent, Page};
5210
5211        // ONE deadline for the whole call. The probe, the injection and the parse check are
5212        // main-thread round trips; on a busy UI thread each could take up to ~10-20 s, and the
5213        // wait for the result used to start its own `timeout` only AFTER them, so a "30 s"
5214        // eval could run for two minutes. Every step below now draws on this one budget (a
5215        // closure the UI thread had not started when it ran out is abandoned — it never runs,
5216        // so "not sent" stays true; one that started is still waited for).
5217        //
5218        // Two floors keep a SHORT timeout from turning load into false failures: sending (the
5219        // liveness probe + injection) may use at least `MIN_SEND_WINDOW` — room for the probe's
5220        // full 2 s, since a probe cut short cannot tell a dead page from a busy one — and code
5221        // that was sent gets at least `MIN_RESULT_WINDOW` (or its whole timeout, if shorter) to
5222        // answer, counted from after the parse check. The bound, worst case: a probe dispatched
5223        // just before the send deadline still gets its 2 s answer window (then the call reports
5224        // NotSent), so a call ends within max(timeout, 2.5 s) + 2 s — plus the grace of a
5225        // main-thread step that had already started when time ran out.
5226        let started = tokio::time::Instant::now();
5227        let deadline = started + timeout;
5228        let send_deadline = deadline.max(started + MIN_SEND_WINDOW);
5229
5230        // The hard concurrency ceiling is enforced atomically at every reservation
5231        // (`reserve_pending`, used by both the probe and the real eval below) — NOT with a
5232        // separate early check, which races: concurrent callers would all pass a stale
5233        // `len()` read before any of them inserts. The probe is the first reservation, so a
5234        // saturated map is rejected fast (before any eval is injected) with the real "too
5235        // many concurrent" cause.
5236
5237        // Wait for the JS bridge ready signal (sent on bridge init) before
5238        // attempting evals.  For explicitly targeted windows the probe
5239        // mechanism is still used because the ready signal only proves that
5240        // *some* webview's bridge loaded — not necessarily the targeted one.
5241        if !self
5242            .state
5243            .bridge_ready
5244            .load(std::sync::atomic::Ordering::Acquire)
5245        {
5246            let notified = self.state.bridge_notify.notified();
5247            if !self
5248                .state
5249                .bridge_ready
5250                .load(std::sync::atomic::Ordering::Acquire)
5251            {
5252                // Leave the probe its full answer window: a page whose bridge never signals ready
5253                // (not loaded, or a window without the `victauri:default` capability) must still
5254                // be probed, so the agent gets that diagnosis rather than "out of time".
5255                let ready_wait = std::time::Duration::from_secs(5).min(
5256                    send_deadline
5257                        .saturating_duration_since(tokio::time::Instant::now())
5258                        .saturating_sub(PROBE_ANSWER_WINDOW),
5259                );
5260                let _ = tokio::time::timeout(ready_wait, notified).await;
5261            }
5262        }
5263
5264        // Subscribe before anything is sent AND check the current value: `subscribe()` marks it
5265        // seen, so a call started after the app's exit signal would otherwise wait out its whole
5266        // timeout for a change that has already happened.
5267        let mut shutdown = self.state.shutdown_tx.subscribe();
5268        if *shutdown.borrow() {
5269            return Err(EvalFailure::new(
5270                NotSent,
5271                "the app is shutting down, so the call was not sent",
5272            ));
5273        }
5274
5275        // Reserved sentinel key for the default (unlabeled) window — cannot
5276        // collide with a real label.
5277        let label_key =
5278            webview_label.map_or_else(|| "\u{1}__default__".to_string(), str::to_string);
5279
5280        // Ready signals from here on may come from a page that replaced the one this eval runs
5281        // in; an earlier one cannot (the probe below then answers from the new page).
5282        let mut loads_seen = self.state.page_loads.current_seq();
5283
5284        // Liveness probe before EVERY eval — on the DEFAULT window as well as
5285        // labeled ones. The probe is a tiny round-trip that returns in ~ms on a
5286        // healthy bridge and fails fast (~2s) on a dead/hung/reloading one, turning
5287        // a full-timeout hang (e.g. 30s) into an immediate, clear "bridge not
5288        // responding" error. This was the #1 live-4DA friction: a webview that
5289        // reloads mid-session (HMR) made the very next tool call hang the full
5290        // timeout, and the DEFAULT window — the most common target — was never
5291        // probed at all. Probing every call (not once-cached) is what guarantees
5292        // *zero* 30s hangs even across repeated reloads; the healthy-path cost is a
5293        // single sub-millisecond localhost round-trip, negligible against the value
5294        // of never stalling an agent into a CDP fallback. It also reports the nonce of the
5295        // page the eval is armed in, which is what tells a reload from a late ready signal.
5296        let prev_timed_out = self.timed_out_labels.lock().await.remove(&label_key);
5297        let armed_nonce = match self
5298            .probe_bridge(webview_label, Some(send_deadline), None)
5299            .await
5300        {
5301            Ok(nonce) => nonce,
5302            // The probe's own delivery was not picked up within the send window: a busy UI
5303            // thread, not a dead page or a failed injection of the agent's code.
5304            Err(e) if e.contains("(it will not run)") => {
5305                return Err(EvalFailure::new(
5306                    NotSent,
5307                    format!(
5308                        "eval ran out of time before the code was sent ({} elapsed, timeout {}) \
5309                         — the app's UI thread is busy (the liveness probe could not be \
5310                         delivered: {e}). The code did NOT run; retry when the app is \
5311                         responsive, or pass a longer timeout.",
5312                        format_timeout(started.elapsed()),
5313                        format_timeout(timeout)
5314                    ),
5315                ));
5316            }
5317            Err(e) => {
5318                return Err(EvalFailure::new(
5319                    NotSent,
5320                    if prev_timed_out {
5321                        format!(
5322                            "{e} (a previous eval on this window also timed out — the webview \
5323                             likely reloaded or the app stopped responding)"
5324                        )
5325                    } else {
5326                        e
5327                    },
5328                ));
5329            }
5330        };
5331
5332        let id = uuid::Uuid::new_v4().to_string();
5333        let (tx, rx) = tokio::sync::oneshot::channel();
5334        let slot = self
5335            .reserve_pending(&id, tx)
5336            .await
5337            .map_err(|e| EvalFailure::new(NotSent, e))?;
5338        // Only the target window may answer (a `None` label is narrowed once it has resolved).
5339        slot.bind_window(webview_label);
5340
5341        // Auto-prepend `return` so bare expressions produce a value — but ONLY
5342        // for single expressions. Multi-statement blocks (or code containing an
5343        // explicit `return`) are used as-is. Prepending `return` to a statement
5344        // block like `foo(); return bar()` would parse as `return foo();` and
5345        // silently discard everything after the first statement (issue: core
5346        // primitive returned wrong/undefined values for "do X, then return Y").
5347        // Prepend to the code with its LEADING comments stripped: `return // note\nexpr`
5348        // parses (ASI) as `return;` and silently yields undefined.
5349        let body = strip_leading_js_comments(code.trim());
5350        let code = if should_prepend_return(body) {
5351            format!("return {body}")
5352        } else {
5353            code.trim().to_string()
5354        };
5355
5356        // Fail fast on a SYNTAX error instead of hanging for the full timeout (audit /
5357        // red-team "malformed eval consumes the full 30s"). The user code is inlined into
5358        // the wrapper; if it has a parse error the WHOLE script fails to parse, so its callback
5359        // never fires. We cannot wrap the code in `new Function`/`AsyncFunction` to surface the
5360        // SyntaxError, because dynamic code generation is gated by the same `unsafe-eval` CSP
5361        // that blocks `eval()` — which is exactly why the bridge uses an inline async-IIFE in
5362        // the first place. Instead a check script (which always parses) is delivered right
5363        // AFTER the wrapper, to the same window: webview evals run in order, and a wrapper that
5364        // parsed has already marked itself begun, so "not begun" means it did not parse. There
5365        // is no timer to race: the check used to be a 750ms watchdog armed BEFORE the code, and
5366        // a busy main thread delaying the code past it reported a parse error for code that
5367        // then ran (an `invoke_command` retried on that ran twice).
5368        // The settle logic lives in the bridge's closure-private state (see `_evalBegin` /
5369        // `_evalCheck` / `_evalSettle` in js_bridge.rs): the page can neither enumerate pending
5370        // eval ids nor suppress results, each outcome is delivered at most once, and
5371        // serialization uses a `JSON.stringify` captured before any page script ran.
5372        // Out of time before the code is sent (a slow probe on a busy UI thread): send nothing —
5373        // the code never runs, and the agent is told exactly that.
5374        if tokio::time::Instant::now() >= send_deadline {
5375            return Err(EvalFailure::new(
5376                NotSent,
5377                format!(
5378                    "eval ran out of time before the code was sent ({} elapsed, timeout {}) — \
5379                     the app's UI thread is busy (the page answered the liveness probe late). \
5380                     The code did NOT run; retry when the app is responsive, or pass a longer \
5381                     timeout.",
5382                    format_timeout(started.elapsed()),
5383                    format_timeout(timeout)
5384                ),
5385            ));
5386        }
5387        let inject = crate::js_bridge::eval_wrapper_script(&id, &code);
5388        let target = {
5389            let _budget = crate::bridge::CallBudget::until(send_deadline.into_std());
5390            self.bridge.eval_webview_resolved(webview_label, &inject)
5391        }
5392        .map_err(|e| {
5393            // A delivery that STARTED on the main thread (then outlived its grace, or panicked)
5394            // may have run the code: never tell the agent it did not.
5395            if crate::bridge::closure_may_have_run(&e) {
5396                EvalFailure::new(
5397                    Aborted,
5398                    format!(
5399                        "eval delivery did not finish: {e}. The code may or may not have run — \
5400                         check the app's state before re-running it."
5401                    ),
5402                )
5403            } else if e.contains("(it will not run)") {
5404                // The UI thread did not pick the delivery up within the call's send window —
5405                // the same exhaustion as the check above, one step later: same diagnosis.
5406                EvalFailure::new(
5407                    NotSent,
5408                    format!(
5409                        "eval ran out of time before the code was sent ({} elapsed, timeout {}) \
5410                         — the app's UI thread is busy ({e}). The code did NOT run; retry when \
5411                         the app is responsive, or pass a longer timeout.",
5412                        format_timeout(started.elapsed()),
5413                        format_timeout(timeout)
5414                    ),
5415                )
5416            } else {
5417                EvalFailure::new(NotSent, format!("eval injection failed: {e}"))
5418            }
5419        })?;
5420        // The code is in the page now: it gets at least `MIN_RESULT_WINDOW` (or its whole
5421        // timeout, if shorter) to answer, even when sending ran close to the deadline.
5422        let result_floor = timeout.min(MIN_RESULT_WINDOW);
5423        let deadline = deadline.max(tokio::time::Instant::now() + result_floor);
5424        let deliver_to = if target.is_empty() {
5425            webview_label
5426        } else {
5427            slot.bind_window(Some(target.as_str()));
5428            Some(target.as_str())
5429        };
5430        let check = crate::js_bridge::eval_check_script(&id, armed_nonce.as_deref());
5431        // The check only runs when the page reported a nonce (see `eval_check_script`) and the
5432        // script reached it; only then can a timeout rule out a parse error.
5433        // Budgeted to the result deadline (the code is already in the page, so the check may use
5434        // the time the result has), and the result floor is re-applied AFTER it: the time the
5435        // check took on a busy UI thread must not eat the window the code's answer gets.
5436        let check_sent = {
5437            let _budget = crate::bridge::CallBudget::until(deadline.into_std());
5438            self.bridge.eval_webview(deliver_to, &check)
5439        };
5440        let deadline = deadline.max(tokio::time::Instant::now() + result_floor);
5441        let parse_check_armed = match check_sent {
5442            Ok(()) => armed_nonce.is_some(),
5443            Err(e) => {
5444                // Only the fast parse-error report is lost; the code itself was delivered.
5445                tracing::debug!("eval parse check not delivered: {e}");
5446                false
5447            }
5448        };
5449
5450        // While waiting, watch for the ways a call ends with NO callback ever coming: the
5451        // target window was destroyed, it loaded a new page, or the app began shutting down.
5452        // All are the EXPECTED outcome of code that closes its own window, navigates or quits
5453        // the app (e.g. invoking a `quit_app` command) — reporting them after the full timeout
5454        // as "an unresolved promise, an infinite loop…" misled agents into thinking the call
5455        // never ran.
5456        //
5457        // The window check runs in its own task: listing windows is a main-thread round trip
5458        // that can take up to ~2x its 10s dispatch timeout (about 20s) on a busy UI, and must neither stall this
5459        // wait nor push it past its deadline. A listing that FAILS (a busy or wedged UI) is not
5460        // evidence of anything — only a successful listing that lacks the window is.
5461        let watched: Option<String> = (!target.is_empty()).then_some(target);
5462        let mut liveness = tokio::time::interval_at(
5463            tokio::time::Instant::now() + EVAL_WINDOW_WATCH_INTERVAL,
5464            EVAL_WINDOW_WATCH_INTERVAL,
5465        );
5466        let mut check: Option<tokio::task::JoinHandle<Result<Vec<String>, String>>> = None;
5467        let armed = armed_nonce.as_deref();
5468        let mut rx = rx;
5469        // A reload that completed while the code was being delivered is already recorded.
5470        let outcome = if let Some(msg) = self
5471            .page_replaced(watched.as_deref(), &mut loads_seen, armed, deadline)
5472            .await
5473        {
5474            Err(Some(msg))
5475        } else {
5476            loop {
5477                tokio::select! {
5478                    // A result that has arrived always wins — over the deadline too, which can be
5479                    // due by the time this loop first runs (sending ran long on a busy UI thread).
5480                    biased;
5481                    r = &mut rx => break Ok(r),
5482                    () = self.state.page_loads.changed() => {
5483                        if let Some(msg) =
5484                            self.page_replaced(watched.as_deref(), &mut loads_seen, armed, deadline).await
5485                        {
5486                            break Err(Some(msg));
5487                        }
5488                    }
5489                    () = tokio::time::sleep_until(deadline) => break Err(None),
5490                    changed = shutdown.changed() => {
5491                        if changed.is_err() || *shutdown.borrow() {
5492                            break Err(Some(
5493                                "the app began shutting down while the call was in flight, so \
5494                                 no result will arrive. If the code/command quits or restarts the \
5495                                 app, this is the expected outcome and it most likely ran — do \
5496                                 not re-run it blindly."
5497                                    .to_string(),
5498                            ));
5499                        }
5500                    }
5501                    _ = liveness.tick(), if watched.is_some() && check.is_none() => {
5502                        // Backstop for a ready signal that arrived between select rounds.
5503                        if let Some(msg) =
5504                            self.page_replaced(watched.as_deref(), &mut loads_seen, armed, deadline).await
5505                        {
5506                            break Err(Some(msg));
5507                        }
5508                        let bridge = Arc::clone(&self.bridge);
5509                        check = Some(tokio::spawn(async move { bridge.try_list_window_labels() }));
5510                    }
5511                    listed = async { check.as_mut().expect("guarded by precondition").await },
5512                        if check.is_some() =>
5513                    {
5514                        check = None;
5515                        if let (Ok(Ok(labels)), Some(label)) = (listed, watched.as_deref())
5516                            && !labels.iter().any(|l| l == label)
5517                        {
5518                            break Err(Some(format!(
5519                                "window '{label}' was closed while the call was in flight, so no \
5520                                 result will arrive. If the code/command closes this window (or \
5521                                 quits the app), this is the expected outcome and it most likely \
5522                                 ran — do not re-run it blindly."
5523                            )));
5524                        }
5525                    }
5526                }
5527            }
5528        };
5529        if let Some(pending_check) = check {
5530            pending_check.abort();
5531        }
5532        let early = match outcome {
5533            Ok(r) => Ok(r),
5534            // The result may have landed while the page change was being confirmed.
5535            Err(Some(msg)) => match rx.try_recv() {
5536                Ok(raw) => Ok(Ok(raw)),
5537                Err(_) => return Err(EvalFailure::new(Aborted, msg)),
5538            },
5539            // ... or right at the deadline: a result that arrived is never reported as a timeout
5540            // (that invites a retry that runs a command twice).
5541            Err(None) => rx.try_recv().map(Ok).map_err(|_| ()),
5542        };
5543        match early {
5544            Ok(Ok(raw)) => {
5545                self.check_bridge_version_once();
5546                if raw.len() > MAX_EVAL_RESULT_LEN {
5547                    return Err(EvalFailure::new(
5548                        Page,
5549                        format!(
5550                            "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
5551                            raw.len()
5552                        ),
5553                    ));
5554                }
5555                unwrap_eval_envelope(raw)
5556            }
5557            Ok(Err(_)) => Err(EvalFailure::new(Aborted, "eval callback channel closed")),
5558            Err(()) => {
5559                // Mark this window so the NEXT eval does a fast liveness probe —
5560                // if the bridge is gone (reloaded/crashed) the next call fails in
5561                // ~2s instead of blocking the full timeout again.
5562                self.timed_out_labels.lock().await.insert(label_key.clone());
5563                let (began, parse_note) = if parse_check_armed {
5564                    (
5565                        "the code began executing but never resolved",
5566                        "(A syntax/parse error is reported immediately, so this is NOT a parse \
5567                         error.) Common causes",
5568                    )
5569                } else {
5570                    (
5571                        "no result arrived",
5572                        "(The fast syntax-error check could not run for this call, so a parse \
5573                         error could not be ruled out — check the code's syntax.) Other causes",
5574                    )
5575                };
5576                Err(EvalFailure::new(
5577                    Aborted,
5578                    format!(
5579                        "eval timed out after {} — {began}. {parse_note}: an unresolved \
5580                         promise, an infinite loop, an `await` on something that never settles, \
5581                         or the webview reloaded / the app stopped responding mid-eval. If the \
5582                         app may have navigated or crashed, retry (the next call fails fast if \
5583                         the bridge is gone).",
5584                        format_timeout(timeout)
5585                    ),
5586                ))
5587            }
5588        }
5589    }
5590
5591    #[cfg(feature = "sqlite")]
5592    async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
5593        let roots = self.db_roots();
5594
5595        let path = if let Some(p) = db_path {
5596            Self::resolve_existing_db_path(&roots, p)?
5597        } else {
5598            // Configured db_search_paths are EXCLUSIVE when set (don't fall back to the
5599            // OS app dirs that hold WebView internals); WebView/engine internal stores are
5600            // excluded and the largest real candidate wins (audit / red-team "wrong DB").
5601            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
5602                roots.clone()
5603            } else {
5604                self.state.db_search_paths.clone()
5605            };
5606            // A directory walk: off the server's async workers (see `app_info`).
5607            tokio::task::spawn_blocking(move || crate::database::select_app_database(&select_dirs))
5608                .await
5609                .map_err(|e| format!("database discovery failed: {e}"))??
5610        };
5611        let path_str = path
5612            .to_str()
5613            .ok_or_else(|| "invalid path encoding".to_string())?
5614            .to_string();
5615
5616        bounded::run_blocking_bounded(
5617            Some(&bounded::DB_SLOTS),
5618            "db health check",
5619            crate::database::DB_HEALTH_META_BUDGET
5620                + DB_HEALTH_COUNT_BUDGET
5621                + DB_HEALTH_CHECK_BUDGET
5622                + bounded::BLOCKING_DEADLINE_SLACK,
5623            move || {
5624                crate::database::db_health_report(
5625                    &path_str,
5626                    DB_HEALTH_COUNT_BUDGET,
5627                    DB_HEALTH_CHECK_BUDGET,
5628                )
5629            },
5630        )
5631        .await
5632    }
5633
5634    fn check_bridge_version_once(&self) {
5635        if self.bridge_checked.swap(true, Ordering::Relaxed) {
5636            return;
5637        }
5638        let handler = self.clone();
5639        tokio::spawn(async move {
5640            match handler
5641                .eval_with_return_timeout(
5642                    "window.__VICTAURI__?.version",
5643                    None,
5644                    std::time::Duration::from_secs(5),
5645                )
5646                .await
5647            {
5648                Ok(v) => {
5649                    let v = v.trim_matches('"');
5650                    if v == BRIDGE_VERSION {
5651                        tracing::debug!("Bridge version verified: {v}");
5652                    } else {
5653                        tracing::warn!(
5654                            "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
5655                        );
5656                    }
5657                }
5658                Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
5659            }
5660        });
5661    }
5662}
5663
5664const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
5665It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
5666(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
5667(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
5668\n\nBACKEND tools (direct Rust access, no webview needed): \
5669'app_info' (app config, directory paths, discovered databases, process info), \
5670'list_app_dir' (browse app data/config/log directories), \
5671'read_app_file' (read files from app directories), \
5672'query_db' (read-only SQLite queries with auto-discovery). \
5673\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
5674'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
5675capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
5676Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
5677capability/security auditing, database diagnostics, plugin state, child process enumeration, \
5678task tracking, and automatic Tauri event bus monitoring. \
5679'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
5680drops, and response corruption into Tauri commands at the Rust layer. \
5681'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
5682activity across IPC + DOM + console + network + window events into a coherent narrative. \
5683\n\nWEBVIEW tools: \
5684'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
5685'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
5686'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
5687\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
5688\n\nCOMPOUND tools with an 'action' parameter: \
5689'window' (get_state, list, manage, resize, move_to, set_title), \
5690'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
5691set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
5692get_events, events_between, get_replay, export, import, replay), \
5693'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
5694\n\nOTHER: verify_state, wait_for (incl. 'expression'/'event' conditions to await \
5695async backend work to true completion), assert_semantic, resolve_command, \
5696app_state (app-defined backend state probes), \
5697get_memory_stats, get_plugin_info, get_diagnostics.";
5698
5699impl ServerHandler for VictauriMcpHandler {
5700    fn get_info(&self) -> InitializeResult {
5701        // NOTE: we advertise `resources` (read) but NOT `resources.subscribe`. A real
5702        // server-initiated `notifications/resources/updated` push was never implemented
5703        // (subscribe/unsubscribe only record intent in memory; nothing emits updates), and
5704        // the default stateless transport has no SSE channel to push over anyway. Advertising
5705        // a subscribe capability we cannot honour misleads clients — read resources on demand.
5706        InitializeResult::new(
5707            ServerCapabilities::builder()
5708                .enable_tools()
5709                .enable_resources()
5710                .build(),
5711        )
5712        .with_instructions(SERVER_INSTRUCTIONS)
5713    }
5714
5715    async fn list_tools(
5716        &self,
5717        _request: Option<PaginatedRequestParams>,
5718        _context: RequestContext<RoleServer>,
5719    ) -> Result<ListToolsResult, ErrorData> {
5720        let all_tools = Self::tool_router().list_all();
5721        let filtered: Vec<Tool> = all_tools
5722            .into_iter()
5723            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
5724            .collect();
5725        // SEP-2549 cache hints: the tool list is fixed for the process lifetime (the
5726        // privacy config that filters it is set at plugin init), so clients may cache
5727        // it. `Private` because the list depends on this instance's privacy profile.
5728        // Legacy (< 2026-07-28) peers never see these fields — rmcp strips them.
5729        let mut result = ListToolsResult::with_all_items(filtered);
5730        result.ttl_ms = Some(LIST_RESULT_TTL_MS);
5731        result.cache_scope = Some(CacheScope::Private);
5732        Ok(result)
5733    }
5734
5735    async fn call_tool(
5736        &self,
5737        request: CallToolRequestParams,
5738        context: RequestContext<RoleServer>,
5739    ) -> Result<CallToolResponse, ErrorData> {
5740        let tool_name: String = request.name.as_ref().to_owned();
5741        // Centralized authorization: gate on the canonical `tool.action` capability
5742        // resolved from the call arguments, matching the REST path in `execute_tool`.
5743        let args_value = serde_json::Value::Object(request.arguments.clone().unwrap_or_default());
5744        // A disabled tool reports "disabled" whatever its arguments look like.
5745        if self.state.privacy.disabled_tools.contains(&tool_name) {
5746            return Ok(tool_disabled(&tool_name).into());
5747        }
5748        let capability = authz::resolve_capability(&tool_name, &args_value)
5749            .map_err(|msg| ErrorData::invalid_params(msg, None))?;
5750        if !self.state.privacy.is_call_allowed(&tool_name, &capability) {
5751            tracing::debug!(tool = %tool_name, capability = %capability, "tool call blocked by privacy config");
5752            return Ok(tool_disabled(&capability).into());
5753        }
5754        self.state
5755            .tool_invocations
5756            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5757        let start = std::time::Instant::now();
5758        tracing::debug!(tool = %tool_name, "tool invocation started");
5759        let ctx = ToolCallContext::new(self, request, context);
5760        // Same panic boundary as the REST path: without it a panicking handler left the MCP
5761        // request unanswered until the client's own timeout.
5762        let response = bounded::CatchUnwind::new(Self::tool_router().call(ctx))
5763            .await
5764            .unwrap_or_else(|panic| {
5765                tracing::error!(tool = %tool_name, "tool handler panicked: {panic}");
5766                Ok(tool_panicked(&tool_name, &panic).into())
5767            });
5768        let elapsed = start.elapsed();
5769        tracing::debug!(
5770            tool = %tool_name,
5771            elapsed_ms = elapsed.as_millis() as u64,
5772            is_error = response.as_ref().map_or(true, |r| match r {
5773                CallToolResponse::Complete(r) => r.is_error.unwrap_or(false),
5774                _ => false,
5775            }),
5776            "tool invocation completed"
5777        );
5778
5779        // Centralized output redaction: apply to all text content so no
5780        // individual tool can accidentally leak secrets. Victauri tools always
5781        // complete in one round trip, so only the `Complete` variant carries
5782        // output; MRTR intermediates (input-required / task) pass through.
5783        if self.state.privacy.redaction_enabled {
5784            response.map(|resp| match resp {
5785                CallToolResponse::Complete(mut r) => {
5786                    for item in &mut r.content {
5787                        if let ContentBlock::Text(tc) = item {
5788                            tc.text = self.state.privacy.redact_output(&tc.text);
5789                        }
5790                    }
5791                    CallToolResponse::Complete(r)
5792                }
5793                other => other,
5794            })
5795        } else {
5796            response
5797        }
5798    }
5799
5800    fn get_tool(&self, name: &str) -> Option<Tool> {
5801        if !self.state.privacy.is_tool_enabled(name) {
5802            return None;
5803        }
5804        Self::tool_router().get(name).cloned()
5805    }
5806
5807    async fn list_resources(
5808        &self,
5809        _request: Option<PaginatedRequestParams>,
5810        _context: RequestContext<RoleServer>,
5811    ) -> Result<ListResourcesResult, ErrorData> {
5812        // SEP-2549 cache hints: the resource *list* (not the contents) is static for
5813        // the process lifetime, so clients may cache it (same rationale as list_tools).
5814        let mut result = ListResourcesResult::with_all_items(vec![
5815            Resource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
5816                .with_description(
5817                    "Live IPC call log — all commands invoked between frontend and backend",
5818                )
5819                .with_mime_type("application/json"),
5820            Resource::new(RESOURCE_URI_WINDOWS, "windows")
5821                .with_description(
5822                    "Current state of all Tauri windows — position, size, visibility, focus",
5823                )
5824                .with_mime_type("application/json"),
5825            Resource::new(RESOURCE_URI_STATE, "state")
5826                .with_description(
5827                    "Victauri plugin state — event count, registered commands, memory stats",
5828                )
5829                .with_mime_type("application/json"),
5830        ]);
5831        result.ttl_ms = Some(LIST_RESULT_TTL_MS);
5832        result.cache_scope = Some(CacheScope::Private);
5833        Ok(result)
5834    }
5835
5836    async fn read_resource(
5837        &self,
5838        request: ReadResourceRequestParams,
5839        _context: RequestContext<RoleServer>,
5840    ) -> Result<ReadResourceResponse, ErrorData> {
5841        let uri = &request.uri;
5842        // Resources bypass the tool dispatcher, so they must apply the same privacy
5843        // gate themselves (audit B1): a strict profile that blocks log/window reads
5844        // as tools must not be able to read the same data via a resource.
5845        if !resource_allowed(&self.state.privacy, uri.as_str()) {
5846            return Err(ErrorData::invalid_request(
5847                format!("resource {uri} is not permitted by the current privacy configuration"),
5848                None,
5849            ));
5850        }
5851        let json = match uri.as_str() {
5852            RESOURCE_URI_IPC_LOG => {
5853                // Use the body-free, capped projection — NOT the full body-carrying
5854                // getIpcLog(). On a busy app the full log blows the eval result cap, the
5855                // eval fails, and we silently fall back to the Rust event_log (which is
5856                // itself default-window-drained) — serving a subset that looks complete.
5857                // trimmed_log_js bounds entries + truncates oversized fields so the
5858                // resource stays correct under load. (Matches the `logs ipc` tool.)
5859                let code = trimmed_log_js(
5860                    &format!("window.__VICTAURI__?.getIpcLog({DEFAULT_LOG_LIMIT})"),
5861                    DEFAULT_LOG_LIMIT,
5862                );
5863                if let Ok(json) = self.eval_with_return(&code, None).await {
5864                    json
5865                } else {
5866                    let calls = self.state.event_log.ipc_calls();
5867                    serde_json::to_string_pretty(&calls)
5868                        .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
5869                }
5870            }
5871            RESOURCE_URI_WINDOWS => {
5872                let states = self
5873                    .bridge
5874                    .try_get_window_states(None)
5875                    .map_err(|e| ErrorData::internal_error(ui_busy(&e), None))?;
5876                serde_json::to_string_pretty(&states)
5877                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
5878            }
5879            RESOURCE_URI_STATE => {
5880                let state_json = serde_json::json!({
5881                    "events_captured": self.state.event_log.len(),
5882                    "commands_registered": self.state.registry.count(),
5883                    "memory": crate::memory::current_stats(),
5884                    "port": self.state.port.load(Ordering::Relaxed),
5885                });
5886                serde_json::to_string_pretty(&state_json)
5887                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
5888            }
5889            _ => {
5890                return Err(ErrorData::resource_not_found(
5891                    format!("unknown resource: {uri}"),
5892                    None,
5893                ));
5894            }
5895        };
5896
5897        let json = if self.state.privacy.redaction_enabled {
5898            self.state.privacy.redact_output(&json)
5899        } else {
5900            json
5901        };
5902
5903        Ok(ReadResourceResult::new(vec![ResourceContents::text(json, uri)]).into())
5904    }
5905
5906    // `resources/subscribe` is legacy-protocol-only under MCP 2026-07-28 (replaced by
5907    // `subscriptions/listen`). Victauri intentionally keeps the legacy handlers: the
5908    // subscribe capability is not advertised (see `get_info`), no update push exists,
5909    // and legacy clients that call anyway get the same recorded-intent behavior as
5910    // before. `allow(deprecated)` because rmcp 3.x marks the trait methods deprecated.
5911    #[allow(deprecated)]
5912    async fn subscribe(
5913        &self,
5914        request: SubscribeRequestParams,
5915        _context: RequestContext<RoleServer>,
5916    ) -> Result<(), ErrorData> {
5917        let uri = &request.uri;
5918        // Same privacy gate as read_resource (audit B1) — don't let a blocked
5919        // resource be subscribed to for push updates.
5920        if !resource_allowed(&self.state.privacy, uri.as_str()) {
5921            return Err(ErrorData::invalid_request(
5922                format!("resource {uri} is not permitted by the current privacy configuration"),
5923                None,
5924            ));
5925        }
5926        match uri.as_str() {
5927            RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
5928                self.subscriptions.lock().await.insert(uri.clone());
5929                tracing::info!("Client subscribed to resource: {uri}");
5930                Ok(())
5931            }
5932            _ => Err(ErrorData::resource_not_found(
5933                format!("unknown resource: {uri}"),
5934                None,
5935            )),
5936        }
5937    }
5938
5939    #[allow(deprecated)]
5940    async fn unsubscribe(
5941        &self,
5942        request: UnsubscribeRequestParams,
5943        _context: RequestContext<RoleServer>,
5944    ) -> Result<(), ErrorData> {
5945        self.subscriptions.lock().await.remove(&request.uri);
5946        tracing::info!("Client unsubscribed from resource: {}", request.uri);
5947        Ok(())
5948    }
5949}
5950
5951/// JS `function trimField(v)` (with its `MB` bound): a string over [`MAX_LOG_FIELD_BYTES`]
5952/// UTF-16 units is cut with a marker, an object whose JSON is larger becomes a size marker.
5953/// The cut never falls between the halves of a surrogate pair — a lone surrogate serializes
5954/// to JSON that `serde_json` rejects, which failed the whole log read.
5955fn trim_field_js() -> String {
5956    let mb = MAX_LOG_FIELD_BYTES;
5957    format!(
5958        r"var MB = {mb};
5959            function trimField(v) {{
5960                if (typeof v === 'string') {{
5961                    if (v.length <= MB) return v;
5962                    var end = MB, c = v.charCodeAt(end - 1);
5963                    if (c >= 0xD800 && c <= 0xDBFF) end--;
5964                    return v.slice(0, end) + '…[+' + (v.length - end) + ' bytes truncated]';
5965                }}
5966                if (v && typeof v === 'object') {{
5967                    var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }}
5968                    if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }}
5969                }}
5970                return v;
5971            }}"
5972    )
5973}
5974
5975/// JS for `check_ipc_integrity`. Classifies the calls from the body-free IPC view and fetches
5976/// full entries (args + result) only for the <= 20 stale and <= 20 errored calls it lists:
5977/// deep-copying every retained body just to count statuses froze the UI thread (R5-JS1).
5978#[doc(hidden)]
5979#[must_use]
5980pub fn ipc_integrity_js(threshold_ms: i64) -> String {
5981    format!(
5982        r"return (function() {{
5983                var V = window.__VICTAURI__;
5984                var log = V?.getIpcLog(0, {{ bodies: false }}) || [];
5985                var now = Date.now();
5986                var threshold = {threshold_ms};
5987                var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
5988                var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
5989                var errored = log.filter(function(c) {{ return c.status === 'error'; }});
5990                var netCount = (V?.getNetworkLog(null, 0, {{ bodies: false }}) || []).length;
5991                var capture = (V && V.getIpcCaptureStatus) ? V.getIpcCaptureStatus() : null;
5992                var warning = null;
5993                if (capture && (capture.capture === 'none' || capture.capture === 'requests_only')) {{
5994                    warning = capture.note;
5995                }} else if (log.length === 0 && netCount > 5) {{
5996                    warning = 'Zero IPC calls captured but ' + netCount + ' network requests observed. IPC capture may not be working — check ipc_capture / get_diagnostics.';
5997                }}
5998                function withBodies(list) {{
5999                    list = list.slice(0, 20);
6000                    if (!list.length) return list;
6001                    var got = V.getIpcLog(0, {{ ids: list.map(function(c) {{ return c.id; }}) }}) || [];
6002                    var byId = {{}};
6003                    for (var i = 0; i < got.length; i++) byId[got[i].id] = got[i];
6004                    return list.map(function(c) {{ return byId[c.id] || c; }});
6005                }}
6006                // INTEGRITY = round-trip soundness: no stuck/stale (never-returned) calls.
6007                // A command that completed with an Err is a HEALTHY round-trip (it returned)
6008                // — every real app exercises error paths, so counting those as 'unhealthy'
6009                // would cry wolf. The error_count/errored_calls surface them for visibility,
6010                // but only stale calls flip `healthy`.
6011                return {{
6012                    healthy: stale.length === 0,
6013                    total_calls: log.length,
6014                    pending_count: pending.length,
6015                    stale_count: stale.length,
6016                    error_count: errored.length,
6017                    stale_calls: withBodies(stale),
6018                    errored_calls: withBodies(errored),
6019                    warning: warning,
6020                    ipc_capture: capture
6021                }};
6022            }})()"
6023    )
6024}
6025
6026/// JS for `logs slow_ipc`: ranks the calls from the body-free IPC view, then fetches full
6027/// (field-trimmed) entries only for the `limit` slowest it returns (R5-JS1).
6028#[doc(hidden)]
6029#[must_use]
6030pub fn slow_ipc_js(threshold_ms: u64, limit: usize) -> String {
6031    let trim_field = trim_field_js();
6032    format!(
6033        r"return (function() {{
6034                {trim_field}
6035                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; }}
6036                var V = window.__VICTAURI__;
6037                var log = V?.getIpcLog(0, {{ bodies: false }}) || [];
6038                var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold_ms}; }});
6039                slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
6040                var top = slow.slice(0, {limit});
6041                if (top.length) {{
6042                    var got = V.getIpcLog(0, {{ ids: top.map(function(c) {{ return c.id; }}) }}) || [];
6043                    var byId = {{}};
6044                    for (var i = 0; i < got.length; i++) byId[got[i].id] = got[i];
6045                    top = top.map(function(c) {{ return byId[c.id] || c; }});
6046                }}
6047                return {{ threshold_ms: {threshold_ms}, count: top.length, calls: top.map(trimEntry) }};
6048            }})()",
6049    )
6050}
6051
6052/// Reads the bridge's IPC capture status (`null` on a bridge without it).
6053const IPC_CAPTURE_STATUS_JS: &str = "return (window.__VICTAURI__ && typeof window.__VICTAURI__.getIpcCaptureStatus === 'function') ? window.__VICTAURI__.getIpcCaptureStatus() : null";
6054
6055/// Build a JS expression that takes an array of log entries (`source_expr`),
6056/// keeps at most `limit` of the most recent, and truncates any per-entry field
6057/// larger than [`MAX_LOG_FIELD_BYTES`]. This keeps IPC/network log results under
6058/// the eval size cap on busy apps where individual entries carry large bodies.
6059///
6060/// The returned code is a complete `return (...)` statement.
6061#[doc(hidden)]
6062#[must_use]
6063pub fn trimmed_log_js(source_expr: &str, limit: usize) -> String {
6064    let trim_field = trim_field_js();
6065    format!(
6066        r"return (function() {{
6067            {trim_field}
6068            function trimEntry(e) {{
6069                if (e == null || typeof e !== 'object') return e;
6070                var out = Array.isArray(e) ? [] : {{}};
6071                for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) out[k] = trimField(e[k]); }}
6072                return out;
6073            }}
6074            var arr = {source_expr} || [];
6075            if (arr.length > {limit}) arr = arr.slice(arr.length - {limit}); // not slice(-0): all
6076            return arr.map(trimEntry);
6077        }})()"
6078    )
6079}
6080
6081/// Wall-clock epoch milliseconds: the floor a new recording epoch's drain reads from. Taken
6082/// BEFORE the recording starts, so nothing logged after the start falls below it. (An imported
6083/// session's old start time used to pull in the page's whole history.)
6084fn now_ms() -> f64 {
6085    #[allow(clippy::cast_precision_loss)]
6086    let ms = chrono::Utc::now().timestamp_millis() as f64;
6087    ms
6088}
6089
6090/// Unwrap the `{"__victauri_ok": <val>, "__victauri_type": <t>}` (or
6091/// `{"__victauri_err": <msg>}`) envelope produced by the eval bridge into the
6092/// value/error string returned to callers.
6093///
6094/// Parsing uses `serde_json`'s default recursion limit (it is intentionally NOT
6095/// disabled — an unbounded recursive parse of a pathologically deep result
6096/// overflows the worker thread stack and crashes the host). When the parse
6097/// fails because the value is too deeply nested, the envelope is stripped by
6098/// string slicing (no recursion) so the actual value is still returned rather
6099/// than leaking the raw envelope string.
6100fn unwrap_eval_envelope(raw: String) -> Result<String, EvalFailure> {
6101    // Page JSON: sanitize lone surrogates first, or one truncated emoji in a result sends it
6102    // down the raw-string fallback below (and every consumer's own parse of it fails too).
6103    if let Ok(envelope) = page_json::parse_page_json::<serde_json::Value>(&raw) {
6104        if let Some(why) = envelope.get("__victauri_not_run") {
6105            return Err(EvalFailure::new(
6106                EvalFailureKind::NotSent,
6107                format!(
6108                    "{PARSE_ERROR_PREFIX} {}",
6109                    why.as_str().unwrap_or("the code did not begin executing")
6110                ),
6111            ));
6112        }
6113        if let Some(err) = envelope.get("__victauri_err") {
6114            return Err(EvalFailure::new(
6115                EvalFailureKind::Page,
6116                format!(
6117                    "JavaScript error: {}",
6118                    err.as_str().unwrap_or("unknown error")
6119                ),
6120            ));
6121        }
6122        if let Some(why) = envelope.get("__victauri_unserializable") {
6123            return Err(EvalFailure::new(
6124                EvalFailureKind::Page,
6125                format!(
6126                    "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.",
6127                    why.as_str().unwrap_or("unknown reason")
6128                ),
6129            ));
6130        }
6131        if envelope.get("__victauri_ok").is_some() {
6132            let js_type = envelope
6133                .get("__victauri_type")
6134                .and_then(|t| t.as_str())
6135                .unwrap_or("value");
6136            return match js_type {
6137                "undefined" => Ok("undefined".to_string()),
6138                "null" => Ok("null".to_string()),
6139                _ => Ok(serde_json::to_string(&envelope["__victauri_ok"])
6140                    .unwrap_or_else(|_| "null".to_string())),
6141            };
6142        }
6143    }
6144    // Fallback for results too deeply nested for the recursion-limited parser. `rfind` is
6145    // correct here: the wrapper appends `,"__victauri_type":"<type>"}` AFTER the entire payload,
6146    // so the real delimiter is structurally the LAST occurrence — a nested object key of the same
6147    // name appears earlier, and inside a string payload the quotes are escaped (`\"`), so neither
6148    // can be the last match. A hostile page therefore can't shift the slice boundary.
6149    if let Some(after) = raw.strip_prefix(r#"{"__victauri_ok":"#)
6150        && let Some(idx) = after.rfind(r#","__victauri_type":"#)
6151    {
6152        return Ok(after[..idx].to_string());
6153    }
6154    if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) {
6155        let msg = after.trim_end_matches('}').trim_matches('"');
6156        return Err(EvalFailure::new(
6157            EvalFailureKind::Page,
6158            format!("JavaScript error: {msg}"),
6159        ));
6160    }
6161    Ok(raw)
6162}
6163
6164/// The eval code for a caller-supplied EXPRESSION (`verify_state`, `assert_semantic`,
6165/// `wait_for` expression): code that returns the expression's value.
6166///
6167/// These used to be wrapped as `return (<expr>)`, so a trailing `;` or a trailing `// comment`
6168/// (which swallowed the closing paren) turned a valid expression into a parse error. A single
6169/// expression is now handed to the eval engine as-is: its string/comment-aware `return`
6170/// auto-prepend already accepts a trailing `;`, trailing comments and multi-line expressions,
6171/// and the wrapper ends the code with a newline. Anything the engine would NOT wrap (an object
6172/// literal `{a: 1}.a`, which reads as a block) keeps the parenthesized form, with trailing `;`s
6173/// dropped and the expression on lines of its own so a trailing line comment cannot swallow the
6174/// closing paren. Multi-statement code stays a syntax error (these tools take an expression).
6175fn expression_eval_code(expr: &str) -> String {
6176    if should_prepend_return(expr) {
6177        return expr.to_string();
6178    }
6179    let body = expr.trim_end_matches(|c: char| c == ';' || is_js_space(c));
6180    format!("return (\n{body}\n);")
6181}
6182
6183/// Parse the value of an expression evaluated by [`expression_eval_code`]. `undefined` (which
6184/// the engine reports as the bare text `undefined`) is read as `null`: JSON has no `undefined`,
6185/// and `null` is what JavaScript's `x == null` groups it with — so `exists` is false and `falsy`
6186/// is true for it instead of the call failing as "not valid JSON".
6187fn parse_expression_value(raw: &str) -> Result<serde_json::Value, serde_json::Error> {
6188    if raw == "undefined" {
6189        return Ok(serde_json::Value::Null);
6190    }
6191    serde_json::from_str(raw)
6192}
6193
6194/// The failure a page action reported, as tool-error text: `{ok: false, …}`, or an object with
6195/// a top-level `error` string and no `ok: true`. Anything else (arrays with per-item errors, an
6196/// audit whose findings contain an `error` field) is a result. The page's recovery hint is kept
6197/// in the same `[hint: …]` form every tool error uses; it is page-controlled, so only a plain
6198/// `UPPER_SNAKE` word is carried over.
6199fn page_action_error(result: &str) -> Option<String> {
6200    let value: serde_json::Value = page_json::parse_page_json(result).ok()?;
6201    let obj = value.as_object()?;
6202    let ok = obj.get("ok").and_then(serde_json::Value::as_bool);
6203    let error = obj.get("error").and_then(serde_json::Value::as_str);
6204    if ok == Some(true) || (ok.is_none() && error.is_none()) {
6205        return None;
6206    }
6207    let mut message = error.map_or_else(
6208        || format!("the page reported failure: {value}"),
6209        str::to_string,
6210    );
6211    if let Some(hint) = obj.get("hint").and_then(serde_json::Value::as_str)
6212        && !hint.is_empty()
6213        && hint.len() <= 32
6214        && hint.bytes().all(|b| b.is_ascii_uppercase() || b == b'_')
6215    {
6216        message.push_str(&format!("\n\n[hint: {hint}]"));
6217    }
6218    Some(message)
6219}
6220
6221/// Statement keywords where a leading `return` would be a syntax error. Matched as whole words
6222/// (followed by any non-identifier byte — `if\t(`, `const\n`, `function*` — or the end).
6223const STMT_KEYWORDS: &[&str] = &[
6224    "return", "if", "for", "while", "switch", "try", "const", "let", "var", "function", "class",
6225    "throw", "do", "debugger", "with",
6226];
6227
6228/// `code` starts with the whole word `word` (not merely a longer identifier sharing its prefix).
6229fn starts_with_word(code: &str, word: &str) -> bool {
6230    code.starts_with(word)
6231        && code[word.len()..]
6232            .chars()
6233            .next()
6234            .is_none_or(|c| !is_js_ident_char(c))
6235}
6236
6237/// Does `code` begin with a statement (not an expression) — a statement keyword, a block, an
6238/// `async function` declaration, or a label (`outer: for …`)? Prepending `return` to any of
6239/// these is a syntax error or changes its meaning.
6240fn starts_with_statement(code: &str) -> bool {
6241    if code.starts_with('{') || STMT_KEYWORDS.iter().any(|k| starts_with_word(code, k)) {
6242        return true;
6243    }
6244    if starts_with_word(code, "async") && starts_with_word(code[5..].trim_start(), "function") {
6245        return true;
6246    }
6247    // A label: an identifier followed (after optional whitespace) by a single `:`.
6248    let ident_len: usize = code
6249        .chars()
6250        .take_while(|&c| is_js_ident_char(c))
6251        .map(char::len_utf8)
6252        .sum();
6253    ident_len > 0
6254        && !code.as_bytes()[0].is_ascii_digit()
6255        && code[ident_len..].trim_start().starts_with(':')
6256}
6257
6258/// Blocking: open `path`, refuse it unless the OPENED file is a regular file (the handler's
6259/// earlier checks ran on the path, which can be swapped for a FIFO or device before the open),
6260/// then read at most `max_bytes + 1` bytes (audit B7: never the whole file; the `+1` detects
6261/// truncation). Returns the bytes, the file's size and its modification time (Unix seconds).
6262pub(crate) fn read_regular_file(
6263    path: &std::path::Path,
6264    max_bytes: usize,
6265) -> Result<(Vec<u8>, usize, Option<u64>), String> {
6266    use std::io::Read;
6267    let f = std::fs::File::open(path).map_err(|e| e.to_string())?;
6268    let metadata = f.metadata().map_err(|e| e.to_string())?;
6269    if !metadata.is_file() {
6270        return Err("not a regular file".to_string());
6271    }
6272    let modified = metadata.modified().ok().map(|t| {
6273        t.duration_since(std::time::SystemTime::UNIX_EPOCH)
6274            .unwrap_or_default()
6275            .as_secs()
6276    });
6277    let mut buf = Vec::new();
6278    f.take(max_bytes as u64 + 1)
6279        .read_to_end(&mut buf)
6280        .map_err(|e| e.to_string())?;
6281    let size = usize::try_from(metadata.len()).unwrap_or(usize::MAX);
6282    Ok((buf, size, modified))
6283}
6284
6285/// Resolve `.` and `..` components without touching the filesystem.
6286#[cfg(feature = "sqlite")]
6287fn lexically_normalize(path: &std::path::Path) -> std::path::PathBuf {
6288    let mut out = std::path::PathBuf::new();
6289    for component in path.components() {
6290        match component {
6291            std::path::Component::CurDir => {}
6292            std::path::Component::ParentDir => {
6293                out.pop();
6294            }
6295            other => out.push(other),
6296        }
6297    }
6298    out
6299}
6300
6301/// A line ending in one of these continues onto the next line (no ASI).
6302const ASI_CONTINUES_AFTER: &[u8] = b"+-*/%&|^!=<>?:,.([{~";
6303/// A line starting with one of these continues the previous line (no ASI) — including `(`,
6304/// `[` and a template literal, which JavaScript itself treats as a continuation.
6305const ASI_CONTINUES_BEFORE: &[u8] = b".?)]}+-*/%&|^=<>,:([`";
6306
6307/// Length of the JavaScript line terminator at byte `i` — LF, CR, CRLF (2), or U+2028 /
6308/// U+2029 (3) — or 0 when there is none there.
6309fn line_terminator_len(bytes: &[u8], i: usize) -> usize {
6310    match bytes.get(i) {
6311        Some(b'\n') => 1,
6312        Some(b'\r') => 1 + usize::from(bytes.get(i + 1) == Some(&b'\n')),
6313        Some(0xE2)
6314            if bytes.get(i + 1) == Some(&0x80) && matches!(bytes.get(i + 2), Some(0xA8 | 0xA9)) =>
6315        {
6316            3
6317        }
6318        _ => 0,
6319    }
6320}
6321
6322/// Index of the first line terminator at or after byte `from`, if any.
6323fn find_line_terminator(bytes: &[u8], from: usize) -> Option<usize> {
6324    (from..bytes.len()).find(|&i| line_terminator_len(bytes, i) > 0)
6325}
6326
6327/// `code` after the rest of its current line (and that line's terminator); `""` if none.
6328fn after_line(code: &str) -> &str {
6329    let bytes = code.as_bytes();
6330    find_line_terminator(bytes, 0).map_or("", |n| &code[n + line_terminator_len(bytes, n)..])
6331}
6332
6333/// Strip leading whitespace and comments: `//`, `/* */`, and the HTML-like `<!--` / `-->`
6334/// line comments (the code starts a line). Every JavaScript line terminator ends a line
6335/// comment — CR and U+2028/U+2029 as well as LF.
6336fn strip_leading_js_comments(mut code: &str) -> &str {
6337    loop {
6338        code = code.trim_start();
6339        if let Some(rest) = code
6340            .strip_prefix("//")
6341            .or_else(|| code.strip_prefix("<!--"))
6342            .or_else(|| code.strip_prefix("-->"))
6343        {
6344            code = after_line(rest);
6345        } else if let Some(rest) = code.strip_prefix("/*") {
6346            code = rest.find("*/").map_or("", |n| &rest[n + 2..]);
6347        } else {
6348            return code;
6349        }
6350    }
6351}
6352
6353/// What opened a bracket, for [`should_prepend_return`]: a `/` right after the `)` of an
6354/// `if`/`while`/`for`/`with` head starts a regex (the statement body), not a division, and `of`
6355/// is a keyword only directly inside a `for (…)` head.
6356#[derive(PartialEq, Clone, Copy)]
6357enum Bracket {
6358    Plain,
6359    ControlHead,
6360    ForHead,
6361}
6362
6363/// String/template/comment scan state for [`should_prepend_return`].
6364#[derive(PartialEq, Clone, Copy)]
6365enum ScanState {
6366    Code,
6367    SingleQuote,
6368    DoubleQuote,
6369    Template,
6370}
6371
6372/// Decide whether to wrap `code` with a leading `return`.
6373///
6374/// Only a single bare expression should get `return` prepended. Code that is a
6375/// multi-statement block, contains an explicit top-level `return`, or starts
6376/// with a statement keyword is used as-is — prepending `return` to such code
6377/// would execute only the first statement and silently discard the rest.
6378///
6379/// The scan is string/template/comment-aware and only treats a `;` or an
6380/// explicit `return` token as significant when it occurs at bracket depth 0
6381/// outside of any string, template literal, or comment.
6382fn should_prepend_return(code: &str) -> bool {
6383    use ScanState::{Code, DoubleQuote, SingleQuote, Template};
6384
6385    let code = strip_leading_js_comments(code.trim());
6386    if code.is_empty() || starts_with_statement(code) {
6387        return false;
6388    }
6389
6390    let bytes = code.as_bytes();
6391    let mut i = 0;
6392    let mut depth: i32 = 0;
6393    let mut state = ScanState::Code;
6394    // Depths at which a template literal's `${` substitution opened: the matching `}` resumes
6395    // the template (else a backtick inside `${'`'}` was read as the template's end).
6396    let mut template_depths: Vec<i32> = Vec::new();
6397    // Only whitespace since the last line terminator (an HTML-like `-->` comment position).
6398    let mut at_line_start = true;
6399    // Every open bracket (and `${`) and what opened it; the index of the last `)` that closed
6400    // an `if`/`while`/`for`/`with` head.
6401    let mut brackets: Vec<Bracket> = Vec::new();
6402    let mut control_head_closed_at: Option<usize> = None;
6403
6404    // Is there a top-level `return` token starting at byte `i` (word-bounded, and not a
6405    // property name such as `obj.return`)?
6406    let is_return_token = |i: usize| -> bool {
6407        let prev_ok = code[..i]
6408            .chars()
6409            .next_back()
6410            .is_none_or(|c| !is_js_ident_char(c));
6411        prev_ok
6412            && code[i..].starts_with("return")
6413            && code[i + 6..]
6414                .chars()
6415                .next()
6416                .is_none_or(|c| !is_js_ident_char(c))
6417            && !preceded_by_dot(code, i)
6418    };
6419
6420    // The last two significant (non-whitespace, non-comment) bytes seen in code, and where the
6421    // last one is — for the ASI and regex-vs-division decisions.
6422    let mut last_sig: Option<u8> = None;
6423    let mut prev_sig: Option<u8> = None;
6424    let mut last_sig_idx = 0usize;
6425
6426    while i < bytes.len() {
6427        let c = bytes[i];
6428        // LF, CR, CRLF and U+2028 / U+2029 are all JavaScript line terminators.
6429        let terminator = line_terminator_len(bytes, i);
6430        if state == Code && terminator > 0 {
6431            let next_start = i + terminator;
6432            if depth <= 0 && asi_ends_statement(code, next_start, last_sig, prev_sig, last_sig_idx)
6433            {
6434                return false;
6435            }
6436            at_line_start = true;
6437            i = next_start;
6438            continue;
6439        }
6440        match state {
6441            Code => {
6442                // HTML-like comments (Script goal): `<!--` anywhere, and `-->` at the start of
6443                // a line, comment out the rest of the line — up to ANY line terminator.
6444                let line_comment = bytes[i..].starts_with(b"<!--")
6445                    || (at_line_start && bytes[i..].starts_with(b"-->"))
6446                    || bytes[i..].starts_with(b"//");
6447                if line_comment {
6448                    i = find_line_terminator(bytes, i).unwrap_or(bytes.len());
6449                    continue;
6450                }
6451                // A non-ASCII character: JavaScript whitespace (NBSP, BOM, …) is skipped like
6452                // any whitespace; anything else is read as part of an identifier (`énew` is a
6453                // name, not the keyword `new`). Line terminators were handled above.
6454                if c >= 0x80 {
6455                    let ch = code.get(i..).and_then(|rest| rest.chars().next());
6456                    let len = ch.map_or(1, char::len_utf8);
6457                    if !ch.is_some_and(is_js_space) {
6458                        at_line_start = false;
6459                        prev_sig = last_sig;
6460                        last_sig = Some(c);
6461                        last_sig_idx = i + len - 1;
6462                    }
6463                    i += len;
6464                    continue;
6465                }
6466                if !c.is_ascii_whitespace() {
6467                    at_line_start = false;
6468                }
6469                let in_for_head = brackets.last() == Some(&Bracket::ForHead);
6470                match c {
6471                    b'\'' => state = SingleQuote,
6472                    b'"' => state = DoubleQuote,
6473                    b'`' => state = Template,
6474                    b'/' if bytes.get(i + 1) == Some(&b'*') => {
6475                        let body_start = i + 2;
6476                        let end = code[body_start..]
6477                            .find("*/")
6478                            .map_or(bytes.len(), |n| body_start + n);
6479                        i = (end + 2).min(bytes.len());
6480                        // A block comment spanning a line break IS a line break for ASI:
6481                        // `a = 1 /*\n*/ b = 2` is two statements.
6482                        if find_line_terminator(&bytes[..end], body_start).is_some() {
6483                            if depth <= 0
6484                                && asi_ends_statement(code, i, last_sig, prev_sig, last_sig_idx)
6485                            {
6486                                return false;
6487                            }
6488                            at_line_start = true;
6489                        }
6490                        continue;
6491                    }
6492                    // After `}` a `/` is division if the brace closed an object literal, and a
6493                    // regex if it closed a block — undecidable here, so run the code as-is.
6494                    b'/' if last_sig == Some(b'}') => return false,
6495                    b'/' if (last_sig == Some(b')')
6496                        && control_head_closed_at == Some(last_sig_idx))
6497                        || slash_starts_regex(
6498                            code,
6499                            last_sig,
6500                            prev_sig,
6501                            last_sig_idx,
6502                            in_for_head,
6503                        ) =>
6504                    {
6505                        // A regex literal: skip it whole (a quote or newline-like character
6506                        // inside it must not be read as code), then its flags.
6507                        i = skip_regex_literal(bytes, i);
6508                        prev_sig = last_sig;
6509                        last_sig = Some(b')'); // an operand, like a closed group
6510                        last_sig_idx = i.saturating_sub(1);
6511                        continue;
6512                    }
6513                    b'(' | b'[' | b'{' => {
6514                        depth += 1;
6515                        let head = if c == b'(' {
6516                            control_head_kind(code, last_sig, last_sig_idx)
6517                        } else {
6518                            Bracket::Plain
6519                        };
6520                        brackets.push(head);
6521                    }
6522                    b')' | b']' | b'}' => {
6523                        depth -= 1;
6524                        if brackets.pop().is_some_and(|b| b != Bracket::Plain) && c == b')' {
6525                            control_head_closed_at = Some(i);
6526                        }
6527                        if c == b'}' && template_depths.last() == Some(&depth) {
6528                            template_depths.pop();
6529                            state = Template;
6530                        }
6531                    }
6532                    // A top-level `;` with more CODE after it (not just a comment) is a
6533                    // multi-statement block.
6534                    b';' if depth <= 0 && !strip_leading_js_comments(&code[i + 1..]).is_empty() => {
6535                        return false;
6536                    }
6537                    // An explicit top-level `return` token means the code already returns.
6538                    b'r' if depth <= 0 && is_return_token(i) => return false,
6539                    _ => {}
6540                }
6541                if !c.is_ascii_whitespace() {
6542                    prev_sig = last_sig;
6543                    last_sig = Some(c);
6544                    last_sig_idx = i;
6545                }
6546            }
6547            SingleQuote | DoubleQuote | Template => {
6548                let close = match state {
6549                    SingleQuote => b'\'',
6550                    DoubleQuote => b'"',
6551                    _ => b'`',
6552                };
6553                if c == b'\\' {
6554                    i += 1;
6555                } else if state == Template && c == b'$' && bytes.get(i + 1) == Some(&b'{') {
6556                    template_depths.push(depth);
6557                    brackets.push(Bracket::Plain);
6558                    depth += 1;
6559                    state = Code;
6560                    i += 1;
6561                } else if c == close {
6562                    state = Code;
6563                    prev_sig = last_sig;
6564                    last_sig = Some(c);
6565                    last_sig_idx = i;
6566                }
6567            }
6568        }
6569        i += 1;
6570    }
6571
6572    true
6573}
6574
6575/// An identifier byte: ASCII letter/digit/`_`/`$`, or any byte of a non-ASCII character (the
6576/// scanner steps over non-ASCII whitespace itself, so what remains is part of a name).
6577fn is_js_ident(b: u8) -> bool {
6578    b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || b >= 0x80
6579}
6580
6581/// JavaScript whitespace outside ASCII: the Unicode space separators (NBSP, U+2000…), and BOM.
6582fn is_js_space(c: char) -> bool {
6583    c.is_whitespace() || c == '\u{feff}'
6584}
6585
6586/// A character that can be part of an identifier (approximately: non-ASCII letters are not
6587/// told apart from other non-ASCII symbols, which are syntax errors anyway).
6588fn is_js_ident_char(c: char) -> bool {
6589    if c.is_ascii() {
6590        c.is_ascii_alphanumeric() || c == '_' || c == '$'
6591    } else {
6592        !is_js_space(c) && !matches!(c, '\u{2028}' | '\u{2029}')
6593    }
6594}
6595
6596/// Start of the identifier that ends just before byte `end` (a char boundary); `end` if none.
6597fn ident_start_before(code: &str, end: usize) -> usize {
6598    let Some(head) = code.get(..end) else {
6599        return end;
6600    };
6601    let mut start = end;
6602    for (idx, c) in head.char_indices().rev() {
6603        if !is_js_ident_char(c) {
6604            break;
6605        }
6606        start = idx;
6607    }
6608    start
6609}
6610
6611/// What a `(` opens, given the last significant token before it: an `if`/`while`/`with` head,
6612/// a `for` (or `for await`) head, or anything else.
6613fn control_head_kind(code: &str, last_sig: Option<u8>, last_sig_idx: usize) -> Bracket {
6614    if !last_sig.is_some_and(is_js_ident) {
6615        return Bracket::Plain;
6616    }
6617    match js_keyword_ending_at(code, last_sig_idx) {
6618        "if" | "while" | "with" => Bracket::ControlHead,
6619        "for" => Bracket::ForHead,
6620        "await" => {
6621            // `for await (`: the word before `await`.
6622            let before = code[..=last_sig_idx]
6623                .strip_suffix("await")
6624                .unwrap_or("")
6625                .trim_end();
6626            match before.len().checked_sub(1) {
6627                Some(end) if js_keyword_ending_at(code, end) == "for" => Bracket::ForHead,
6628                _ => Bracket::Plain,
6629            }
6630        }
6631        _ => Bracket::Plain,
6632    }
6633}
6634
6635/// Is the token starting at byte `start` a property name (`obj.of`, `a?.return`)? Such a
6636/// word is never a keyword.
6637fn preceded_by_dot(code: &str, start: usize) -> bool {
6638    code[..start].trim_end().ends_with('.')
6639}
6640
6641/// The KEYWORD candidate that ends at byte `end` (inclusive): the identifier there, or `""`
6642/// when `code[end]` is not an identifier byte or the word is a property name after `.`.
6643fn js_keyword_ending_at(code: &str, end: usize) -> &str {
6644    let bytes = code.as_bytes();
6645    if !bytes.get(end).copied().is_some_and(is_js_ident) || !code.is_char_boundary(end + 1) {
6646        return "";
6647    }
6648    let start = ident_start_before(code, end + 1);
6649    if start > end || preceded_by_dot(code, start) {
6650        return "";
6651    }
6652    &code[start..=end]
6653}
6654
6655/// Keywords after which an expression (not a statement end) must follow.
6656///
6657/// NOT listed: `yield`, which is a plain identifier inside the async-arrow wrapper eval code
6658/// runs in (it is a keyword only in generators and strict code), and `of`, which is a keyword
6659/// only inside a `for (… of …)` head — see [`is_expr_keyword`].
6660const EXPR_KEYWORDS: &[&str] = &[
6661    "instanceof",
6662    "in",
6663    "typeof",
6664    "void",
6665    "delete",
6666    "new",
6667    "await",
6668    "return",
6669    "case",
6670    "do",
6671    "else",
6672    "throw",
6673];
6674
6675/// Whether `word` is a keyword after which an operand must follow. `of` is one only directly
6676/// inside a `for (…)` head (`in_for_head`); anywhere else it is an identifier (`f(of / 2)`, or
6677/// `of` then a new line then `foo()`, which is two statements).
6678fn is_expr_keyword(word: &str, in_for_head: bool) -> bool {
6679    (word == "of" && in_for_head) || EXPR_KEYWORDS.contains(&word)
6680}
6681
6682/// Is the `.` at byte `dot` the end of a numeric literal (`1.`) — a complete operand — rather
6683/// than a member access? Only a plain decimal integer (digits and `_`, not itself after a `.`)
6684/// qualifies: `a1.`, `x.`, `0x1.`, `1e3.`, `1n.` and `1.5.` are member accesses. A misread
6685/// only matters in one direction: reading a member access as a complete number merely skips
6686/// the `return` prepend, which is always safe.
6687fn dot_completes_number(code: &str, dot: usize) -> bool {
6688    let bytes = code.as_bytes();
6689    let start = ident_start_before(code, dot);
6690    let word = &bytes[start..dot];
6691    word.first().is_some_and(u8::is_ascii_digit)
6692        && word.iter().all(|b| b.is_ascii_digit() || *b == b'_')
6693        && !(start > 0 && bytes[start - 1] == b'.')
6694}
6695
6696/// A line (or operand) ending in POSTFIX `++`/`--`.
6697fn ends_in_postfix(last_sig: Option<u8>, prev_sig: Option<u8>) -> bool {
6698    matches!(
6699        (prev_sig, last_sig),
6700        (Some(b'+'), Some(b'+')) | (Some(b'-'), Some(b'-'))
6701    )
6702}
6703
6704/// Does a line break just before `next_start` end the statement (JavaScript ASI)? Only
6705/// consulted at bracket depth 0.
6706fn asi_ends_statement(
6707    code: &str,
6708    next_start: usize,
6709    last_sig: Option<u8>,
6710    prev_sig: Option<u8>,
6711    last_sig_idx: usize,
6712) -> bool {
6713    let rest = strip_leading_js_comments(&code[next_start.min(code.len())..]);
6714    let Some(&next) = rest.as_bytes().first() else {
6715        return false; // nothing follows
6716    };
6717    // Restricted production: `a\n++b` is `a; ++b`.
6718    if rest.starts_with("++") || rest.starts_with("--") {
6719        return true;
6720    }
6721    // After POSTFIX `++`/`--` nothing but a binary operator can continue the expression, and
6722    // `i++\n[…]` / `i++\n(…)` are ASI'd into two statements — treat any following line as a
6723    // new statement (not prepending is always safe; prepending would drop that line).
6724    if ends_in_postfix(last_sig, prev_sig) {
6725        return true;
6726    }
6727    // A line ending in `1.` ends in a complete number, not a member access: `1.` then a new
6728    // line then `f()` is two statements.
6729    let number_dot = last_sig == Some(b'.') && dot_completes_number(code, last_sig_idx);
6730    let continues_after = (!number_dot
6731        && last_sig.is_some_and(|p| ASI_CONTINUES_AFTER.contains(&p)))
6732        || is_expr_keyword(js_keyword_ending_at(code, last_sig_idx), false);
6733    !(continues_after || ASI_CONTINUES_BEFORE.contains(&next))
6734}
6735
6736/// Whether a `/` (not starting a comment) begins a regex literal rather than division:
6737/// true where an operand is expected — at the start, after an operator or opening
6738/// punctuation, or after a keyword such as `return`/`typeof`. After a postfix `++`/`--`
6739/// (a complete operand) it is division. (After the `)` of a control-statement head it is a
6740/// regex too; the caller decides that case.)
6741fn slash_starts_regex(
6742    code: &str,
6743    last_sig: Option<u8>,
6744    prev_sig: Option<u8>,
6745    last_sig_idx: usize,
6746    in_for_head: bool,
6747) -> bool {
6748    if ends_in_postfix(last_sig, prev_sig) {
6749        return false;
6750    }
6751    match last_sig {
6752        None => true,
6753        Some(b) if b"(,=:[!&|?{};+-*%<>~^".contains(&b) => true,
6754        Some(b) if is_js_ident(b) => {
6755            is_expr_keyword(js_keyword_ending_at(code, last_sig_idx), in_for_head)
6756        }
6757        Some(_) => false,
6758    }
6759}
6760
6761/// Skip a regex literal starting at the `/` at `start`; returns the index after its flags.
6762fn skip_regex_literal(bytes: &[u8], start: usize) -> usize {
6763    let mut i = start + 1;
6764    let mut in_class = false;
6765    while i < bytes.len() {
6766        match bytes[i] {
6767            b'\\' => i += 1,
6768            b'[' => in_class = true,
6769            b']' => in_class = false,
6770            b'/' if !in_class => break,
6771            // Unterminated at a line terminator (any of them): let it be scanned normally.
6772            _ if line_terminator_len(bytes, i) > 0 => return i,
6773            _ => {}
6774        }
6775        i += 1;
6776    }
6777    i += 1;
6778    while i < bytes.len() && is_js_ident(bytes[i]) {
6779        i += 1;
6780    }
6781    i
6782}
6783
6784#[cfg(test)]
6785mod prop_tests {
6786    //! Property-based tests for the eval auto-return heuristic — the code that
6787    //! caused the worst bug in the system (silent corruption of multi-statement
6788    //! eval) and has bitten twice. These generate many JS-ish snippets and
6789    //! assert the invariants that keep eval correct.
6790    use super::should_prepend_return;
6791    use proptest::prelude::*;
6792
6793    /// A small set of non-keyword identifier-ish expressions.
6794    fn ident() -> impl Strategy<Value = String> {
6795        prop_oneof![
6796            Just("a".to_string()),
6797            Just("x".to_string()),
6798            Just("foo".to_string()),
6799            Just("window.x".to_string()),
6800            Just("document.title".to_string()),
6801            Just("obj.prop".to_string()),
6802            Just("arr[0]".to_string()),
6803            Just("localStorage".to_string()),
6804        ]
6805    }
6806
6807    /// A single bare expression: never starts with a statement keyword, has no
6808    /// top-level `;`, and contains no `return`.
6809    fn bare_expr() -> impl Strategy<Value = String> {
6810        prop_oneof![
6811            ident(),
6812            (ident(), ident()).prop_map(|(a, b)| format!("{a} + {b}")),
6813            (ident(), ident()).prop_map(|(a, b)| format!("{a}({b})")),
6814            ident().prop_map(|a| format!("{a}.length")),
6815            any::<u16>().prop_map(|n| n.to_string()),
6816        ]
6817    }
6818
6819    proptest! {
6820        /// Must never panic or hang on ANY input — including malformed code,
6821        /// unbalanced quotes, and arbitrary unicode (the scanner indexes bytes).
6822        #[test]
6823        fn never_panics_on_arbitrary_input(s in ".{0,256}") {
6824            let _ = should_prepend_return(&s);
6825        }
6826
6827        /// A single bare expression is safe to wrap with `return` → true.
6828        #[test]
6829        fn bare_expressions_are_prepended(e in bare_expr()) {
6830            prop_assert!(should_prepend_return(&e), "bare expr not prepended: {e:?}");
6831        }
6832
6833        /// THE critical bug class: `<expr>; return <expr>` must NOT be prepended
6834        /// (else `return <expr>;` runs and the rest is silently discarded).
6835        #[test]
6836        fn semicolon_multistatement_with_return_never_prepended(
6837            setup in bare_expr(), ret in bare_expr()
6838        ) {
6839            let code = format!("{setup}; return {ret}");
6840            prop_assert!(!should_prepend_return(&code), "would corrupt: {code:?}");
6841        }
6842
6843        /// Newline-separated (ASI) explicit return must also be left as-is.
6844        #[test]
6845        fn newline_explicit_return_never_prepended(pre in bare_expr(), ret in bare_expr()) {
6846            let code = format!("{pre}\nreturn {ret}");
6847            prop_assert!(!should_prepend_return(&code), "explicit return prepended: {code:?}");
6848        }
6849
6850        /// A line after a postfix `++`/`--`, or after a keyword-named PROPERTY (`obj.of`,
6851        /// `obj.in`), starts a new statement: prepending would silently drop it (audit V-6).
6852        #[test]
6853        fn statement_after_postfix_or_keyword_property_never_prepended(
6854            a in ident(), b in bare_expr(), op in prop_oneof![Just("++"), Just("--")],
6855            open in prop_oneof![Just("["), Just("(")], prop in prop_oneof![Just("of"), Just("in")]
6856        ) {
6857            let close = if open == "[" { "]" } else { ")" };
6858            let postfix = format!("{a}{op}\n{open}{b}{close}");
6859            prop_assert!(!should_prepend_return(&postfix), "would drop a line: {postfix:?}");
6860            let keyword_prop = format!("{a}.{prop}\n{b}");
6861            prop_assert!(!should_prepend_return(&keyword_prop), "would drop a line: {keyword_prop:?}");
6862        }
6863
6864        /// `;` or the word `return` INSIDE a string literal must not trigger a
6865        /// false multi-statement split — a bare string is one expression.
6866        #[test]
6867        fn semicolons_and_return_inside_strings_are_ignored(inner in "[a-z0-9;= ]{0,24}") {
6868            // `inner` never contains a quote, so the literal is well-formed.
6869            let code = format!("'do;not;split return {inner}'");
6870            prop_assert!(should_prepend_return(&code), "string literal mis-split: {code:?}");
6871        }
6872    }
6873}
6874
6875#[cfg(test)]
6876mod tests {
6877    use super::*;
6878
6879    #[cfg(feature = "sqlite")]
6880    #[test]
6881    fn database_path_resolution_rejects_lexical_escape() {
6882        let dir = tempfile::tempdir().unwrap();
6883        let root = dir.path().join("allowed");
6884        std::fs::create_dir(&root).unwrap();
6885        std::fs::File::create(dir.path().join("outside.db")).unwrap();
6886
6887        let err =
6888            VictauriMcpHandler::resolve_existing_db_path(&[root], "../outside.db").unwrap_err();
6889        assert!(err.contains("path traversal"), "unexpected error: {err}");
6890    }
6891
6892    #[cfg(feature = "sqlite")]
6893    #[test]
6894    fn database_path_resolution_accepts_contained_nested_file() {
6895        let dir = tempfile::tempdir().unwrap();
6896        let root = dir.path().join("allowed");
6897        let nested = root.join("nested");
6898        std::fs::create_dir_all(&nested).unwrap();
6899        let db = nested.join("app.db");
6900        std::fs::File::create(&db).unwrap();
6901
6902        let resolved =
6903            VictauriMcpHandler::resolve_existing_db_path(&[root], "nested/app.db").unwrap();
6904        // Resolution returns the CANONICAL validated path (opened == validated, closing the
6905        // lexical-vs-canonical TOCTOU), so compare against the canonical form of the target.
6906        assert_eq!(resolved, std::fs::canonicalize(&db).unwrap());
6907    }
6908
6909    #[cfg(all(feature = "sqlite", unix))]
6910    #[test]
6911    fn database_path_resolution_rejects_symlink_escape() {
6912        use std::os::unix::fs::symlink;
6913
6914        let dir = tempfile::tempdir().unwrap();
6915        let root = dir.path().join("allowed");
6916        std::fs::create_dir(&root).unwrap();
6917        let outside = dir.path().join("outside.db");
6918        std::fs::File::create(&outside).unwrap();
6919        symlink(&outside, root.join("linked.db")).unwrap();
6920
6921        let err =
6922            VictauriMcpHandler::resolve_existing_db_path(std::slice::from_ref(&root), "linked.db")
6923                .unwrap_err();
6924        // Audit F6: an escape answers exactly like a miss (no existence oracle).
6925        let miss = VictauriMcpHandler::resolve_existing_db_path(&[root], "absent.db").unwrap_err();
6926        assert!(
6927            err.contains("database not found"),
6928            "unexpected error: {err}"
6929        );
6930        assert_eq!(
6931            err.replace("linked.db", "X"),
6932            miss.replace("absent.db", "X")
6933        );
6934    }
6935
6936    #[cfg(feature = "sqlite")]
6937    #[test]
6938    fn sqlite_identifier_quoting_handles_hostile_table_names() {
6939        let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap();
6940        let conn = rusqlite::Connection::open(file.path()).unwrap();
6941        let name = "odd\"] table";
6942        let identifier = crate::database::quote_sqlite_identifier(name);
6943        conn.execute_batch(&format!(
6944            "CREATE TABLE {identifier} (id INTEGER); INSERT INTO {identifier} VALUES (1);"
6945        ))
6946        .unwrap();
6947        let count: i64 = conn
6948            .query_row(&format!("SELECT count(*) FROM {identifier}"), [], |row| {
6949                row.get(0)
6950            })
6951            .unwrap();
6952        assert_eq!(count, 1);
6953    }
6954
6955    #[test]
6956    fn env_filter_drops_secrets_keeps_safe() {
6957        // Safe, non-secret vars pass.
6958        assert!(is_safe_env_key("HOME"));
6959        assert!(is_safe_env_key("LANG"));
6960        assert!(is_safe_env_key("TAURI_ENV_PLATFORM"));
6961        assert!(is_safe_env_key("VICTAURI_PORT"));
6962        // Secret-looking vars are dropped even under a safe prefix (audit #5).
6963        assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY"));
6964        assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"));
6965        assert!(!is_safe_env_key("VICTAURI_AUTH_TOKEN"));
6966        assert!(!is_safe_env_key("VICTAURI_API_KEY"));
6967        // Unknown prefixes are dropped regardless.
6968        assert!(!is_safe_env_key("AWS_SECRET_ACCESS_KEY"));
6969        assert!(!is_safe_env_key("RANDOM_VAR"));
6970        // The broad TAURI_ namespace is no longer allowed — only TAURI_ENV_ — so
6971        // app-custom TAURI_ secrets are dropped even without a denylist hit.
6972        assert!(!is_safe_env_key("TAURI_CUSTOM_THING"));
6973        // Adversarial leaks closed (audit #5 follow-up): connection strings,
6974        // passphrases, PATs, JWTs, etc. under an allowed prefix.
6975        assert!(!is_safe_env_key("VICTAURI_DB_DSN"));
6976        assert!(!is_safe_env_key("VICTAURI_SIGNING_PASSPHRASE"));
6977        assert!(!is_safe_env_key("VICTAURI_GH_PAT"));
6978        assert!(!is_safe_env_key("VICTAURI_JWT"));
6979        assert!(!is_safe_env_key("VICTAURI_SESSION_ID"));
6980    }
6981
6982    #[test]
6983    fn prepend_return_newline_separated_statements_are_not_wrapped() {
6984        // ASI: `return foo()\nbar()` returns foo() and never runs bar().
6985        assert!(!should_prepend_return("foo()\nbar()"));
6986        assert!(!should_prepend_return("window.x = 1\nwindow.x + 1"));
6987        assert!(!should_prepend_return("foo() // note\nbar()"));
6988        // …but an expression that visibly continues across lines is still one expression.
6989        assert!(should_prepend_return(
6990            "document\n  .querySelector('x')\n  .textContent"
6991        ));
6992        assert!(should_prepend_return("a +\n  b"));
6993        assert!(should_prepend_return("cond\n  ? a\n  : b"));
6994        assert!(should_prepend_return("[1, 2].map(x =>\n  x * 2)"));
6995        assert!(should_prepend_return("`line1\nline2`"));
6996        assert!(should_prepend_return("document.title\n"));
6997    }
6998
6999    /// The live tool list (name + description), in router order — the source of truth for the
7000    /// CLI bridge's baked `tools_fallback.json` (what an agent sees while the app is down).
7001    fn live_tool_manifest() -> serde_json::Value {
7002        let mut tools = VictauriMcpHandler::tool_router().list_all();
7003        tools.sort_by(|a, b| a.name.cmp(&b.name));
7004        serde_json::Value::Array(
7005            tools
7006                .iter()
7007                .map(|t| {
7008                    serde_json::json!({
7009                        "name": t.name.as_ref(),
7010                        "description": t.description.as_deref().unwrap_or_default(),
7011                    })
7012                })
7013                .collect(),
7014        )
7015    }
7016
7017    fn fallback_manifest_path() -> std::path::PathBuf {
7018        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
7019            .join("../victauri-cli/src/tools_fallback.json")
7020    }
7021
7022    #[test]
7023    fn cli_fallback_tool_manifest_matches_the_live_tools() {
7024        // Names AND descriptions: a stale description shown while the app is down misleads an
7025        // agent just as much as a missing tool. Regenerate with:
7026        //   VICTAURI_WRITE_FALLBACK=1 cargo test -p victauri-plugin --lib cli_fallback
7027        let path = fallback_manifest_path();
7028        let Ok(raw) = std::fs::read_to_string(&path) else {
7029            return; // packaged crate: the CLI source is not alongside
7030        };
7031        let live = live_tool_manifest();
7032        if std::env::var_os("VICTAURI_WRITE_FALLBACK").is_some() {
7033            let pretty = serde_json::to_string_pretty(&live).unwrap() + "\n";
7034            std::fs::write(&path, pretty).unwrap();
7035            return;
7036        }
7037        let mut baked: Vec<serde_json::Value> = serde_json::from_str(&raw).unwrap();
7038        baked.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
7039        assert_eq!(
7040            serde_json::Value::Array(baked),
7041            live,
7042            "crates/victauri-cli/src/tools_fallback.json is stale — regenerate it with \
7043             VICTAURI_WRITE_FALLBACK=1 cargo test -p victauri-plugin --lib cli_fallback"
7044        );
7045    }
7046
7047    #[cfg(feature = "sqlite")]
7048    #[test]
7049    fn db_path_outside_roots_gives_no_existence_oracle() {
7050        let root = tempfile::tempdir().unwrap();
7051        let roots = vec![root.path().to_path_buf()];
7052        let outside_existing = std::env::current_exe().unwrap();
7053        let outside_missing = outside_existing.with_file_name("definitely-not-here-7f3a.db");
7054        let e1 = VictauriMcpHandler::resolve_existing_db_path(
7055            &roots,
7056            outside_existing.to_str().unwrap(),
7057        )
7058        .unwrap_err();
7059        let e2 =
7060            VictauriMcpHandler::resolve_existing_db_path(&roots, outside_missing.to_str().unwrap())
7061                .unwrap_err();
7062        assert!(e1.contains("not within an allowed directory"), "{e1}");
7063        assert!(e2.contains("not within an allowed directory"), "{e2}");
7064        assert!(!e1.contains("not found") && !e2.contains("not found"));
7065        // `..` is refused outright in an absolute path (audit F6: the OS resolves it after
7066        // following any symlink, so lexical normalization cannot vouch for it).
7067        let climb = root.path().join("..").join("x.db");
7068        let e3 = VictauriMcpHandler::resolve_existing_db_path(&roots, climb.to_str().unwrap())
7069            .unwrap_err();
7070        assert!(e3.contains("'..' is rejected"), "{e3}");
7071    }
7072
7073    #[test]
7074    fn prepend_return_handles_the_red_team_asi_cases() {
7075        // Two statements that must NOT be wrapped (the second line would silently never run).
7076        for code in [
7077            "window.x++\nwindow.x",
7078            "window.x--\nwindow.x",
7079            "a\n++b",
7080            "/re/.test(s)\nfoo()",
7081            "s.replace(/\"/g, '')\nfoo()",
7082            "foo()\u{2028}bar()",
7083            "foo()\u{2029}bar()",
7084        ] {
7085            assert!(!should_prepend_return(code), "must not wrap: {code:?}");
7086        }
7087        // Valid single expressions spanning lines, which must still be wrapped.
7088        for code in [
7089            "a instanceof\nB",
7090            "typeof\nx",
7091            "await\nfoo()",
7092            "new\nFoo()",
7093            "a /\nb",
7094            "s.split(/,/)\n.length",
7095            "document.title; // trailing note",
7096            "document.title // trailing note",
7097            "obj.return",
7098            "obj.of\n.length",
7099            "i++ / 2",
7100            "`a${'`'}b`",
7101        ] {
7102            assert!(should_prepend_return(code), "must wrap: {code:?}");
7103        }
7104        // Round 2 (0.9 audit V-6): each of these used to be wrapped and silently lost a
7105        // statement, or was wrapped into a syntax error.
7106        for code in ASI_ROUND2_STATEMENT_CASES {
7107            assert!(!should_prepend_return(code), "must not wrap: {code:?}");
7108        }
7109    }
7110
7111    /// Run a Node script and return its stdout, or `None` (test skipped) without `node`.
7112    fn run_node(script: &str) -> Option<String> {
7113        let out = std::process::Command::new("node")
7114            .arg("-e")
7115            .arg(script)
7116            .output()
7117            .ok()?;
7118        Some(String::from_utf8_lossy(&out.stdout).into_owned())
7119    }
7120
7121    #[test]
7122    fn log_field_truncation_never_splits_a_surrogate_pair() {
7123        // An emoji straddling the MAX_LOG_FIELD_BYTES cut used to leave a lone high surrogate,
7124        // which serde_json rejects — failing the whole `logs` read (audit V-4).
7125        let source = format!(
7126            "[{{ body: 'a'.repeat({}) + '\\u{{1F600}}tail' }}]",
7127            MAX_LOG_FIELD_BYTES - 1
7128        );
7129        let code = trimmed_log_js(&source, 10);
7130        let script = format!(
7131            "const r = (function() {{ {code} }})(); \
7132             console.log('OUT:' + r[0].body.isWellFormed() + ':' + r[0].body.indexOf('bytes truncated'));"
7133        );
7134        let Some(out) = run_node(&script) else {
7135            eprintln!("SKIP: node not installed");
7136            return;
7137        };
7138        let line = out
7139            .lines()
7140            .find_map(|l| l.strip_prefix("OUT:"))
7141            .unwrap_or_else(|| panic!("no output: {out}"));
7142        let (well_formed, marker_at) = line.split_once(':').unwrap();
7143        assert_eq!(
7144            well_formed, "true",
7145            "truncated field is not well-formed UTF-16"
7146        );
7147        assert_ne!(marker_at, "-1", "field was not truncated");
7148    }
7149
7150    /// Multi-statement snippets from the 0.9 red-team round (audit V-6): each must run every
7151    /// statement. `f()` pushes `'f'` onto the log.
7152    const ASI_ROUND2_STATEMENT_CASES: &[&str] = &[
7153        "i++ / 2; f()",
7154        "x = {} / 1; f()",
7155        "i++\n[f()]",
7156        "i++\n(f)()",
7157        "i--\n[f()]",
7158        "i--\n(f)()",
7159        "obj.of\nf()",
7160        "obj.in\nf()",
7161        "if\t(true) f()",
7162        "if\n(true) f()",
7163        "const\ny = 5; f()",
7164        "outer: for (const a of [1]) { f() }",
7165        "outer:\nfor (const a of [1]) { f() }",
7166        "`a${'`'}b`; f()",
7167        "`${'`'}`\nf()",
7168        "f() <!-- don't\nf()",
7169        "f()\n--> it's a comment\nf()",
7170        "a = /[/]/\nf()",
7171        "typeof x / 2; f()",
7172    ];
7173
7174    /// Multi-statement snippets from the 0.9 round-4 audit (R4-EVAL2): each was wrapped with
7175    /// `return` and silently lost everything after its first statement.
7176    const ASI_ROUND4_STATEMENT_CASES: &[&str] = &[
7177        // (a) a block comment spanning a line break is a line break
7178        "x = 1 /*\n*/ f()",
7179        "x = 1 /*\r*/ f()",
7180        "x = 1 /*\u{2028}*/ f()",
7181        "x = 1 /* a\n b */ f()",
7182        // (b) a lone CR (and CRLF) is a line terminator everywhere
7183        "x = 1\rf()",
7184        "x = 1\r\nf()",
7185        "x = 1 // note\rf()",
7186        "x = 1 // note\u{2028}f()",
7187        "f() <!-- c\rf()",
7188        "f()\r--> c\rf()",
7189        "x = /a/g\rf()",
7190        // (c) a number ending in `.` is a complete operand
7191        "x = 1.\nf()",
7192        "x = 1_0.\rf()",
7193        "x = 10.\n\nf()",
7194        // (d) `yield` is an identifier in the eval wrapper; `of` is one at depth 0
7195        "x = typeof yield\nf()",
7196        "x = typeof of\nf()",
7197    ];
7198
7199    /// Multi-statement snippets from the 0.9 round-5 audit (R5-EVAL1): each was wrapped with
7200    /// `return` and lost its second statement. (1) a regex right after the `)` of an
7201    /// `if`/`while`/`for`/`with` head was read as division, so a quote or bracket inside it
7202    /// broke the scan; (2) `of` was a keyword anywhere inside brackets; (3) a non-ASCII
7203    /// identifier ending in a keyword (`énew`) was read as that keyword.
7204    const ASI_ROUND5_STATEMENT_CASES: &[&str] = &[
7205        "(function(){ if (a) /'/.test('q') })(); f()",
7206        "(function(){ if (a) /\\(/.test('q') })(); f()",
7207        "(function(){ if (a) /\"/.test('q') })(); f()",
7208        "(function(){ if (a) /`/.test('q') })(); f()",
7209        "(function(){ if (a) /[(]/.test('q') })(); f()",
7210        "(function(){ if (a) /\\[/.test('q') })(); f()",
7211        "(function(){ if (a) /\\{/.test('q') })(); f()",
7212        "(function(){ if ((a)) /'/.test('q') })(); f()",
7213        "(function(){ while (a) /'/.test('q') })(); f()",
7214        "(function(){ for (;a;) /'/.test('q') })(); f()",
7215        "(function(){ with (obj) /'/.test('q') })(); f()",
7216        "(async function(){ for await (const q of []) /'/.test('q') })(); f()",
7217        "(() => { if (a) /'/.test('q') })(); f()",
7218        "Math.abs(of / 2); f()",
7219        "Math.abs(of / 2); f() / 1",
7220        "[of / 2]; f()",
7221        "énew / 2; f() / 1",
7222        "x = énew / 2; f()",
7223        "x = \u{e9}typeof / 2; f()",
7224    ];
7225
7226    #[test]
7227    fn prepend_return_round5_shapes() {
7228        for code in ASI_ROUND5_STATEMENT_CASES {
7229            assert!(!should_prepend_return(code), "must not wrap: {code:?}");
7230        }
7231        for code in [
7232            "(x) / 2 / 1",
7233            "f(x) / 2",
7234            "f(of / 2)",
7235            "[1, 2].map(of => of / 2)",
7236            "énew / 2",
7237            "if_ (a) / 2",
7238            "obj.if (a) / 2",
7239            "(a) / 2; ",
7240        ] {
7241            assert!(should_prepend_return(code), "must wrap: {code:?}");
7242        }
7243    }
7244
7245    /// Table for R4-EVAL2: `(code, wrap?)` — every shape the fix touches, both ways.
7246    #[test]
7247    fn prepend_return_round4_shapes() {
7248        for code in ASI_ROUND4_STATEMENT_CASES {
7249            assert!(!should_prepend_return(code), "must not wrap: {code:?}");
7250        }
7251        for code in [
7252            "x /* no line break */ + 1",
7253            "x /*\n*/ + 1",
7254            "x +\r1",
7255            "x\r.toString()",
7256            "a instanceof\r\nB",
7257            "1.5\n.toFixed(1)",
7258            "obj.of.\nlength",
7259            "0x10.\ntoString()",
7260            "1e3.\ntoFixed(0)",
7261            "(1).\ntoFixed(0)",
7262            "document.title // trailing note\r",
7263            "-->x\ndocument.title",
7264            "<!-- x\rdocument.title",
7265            "// lead\u{2028}document.title",
7266            "[1].map(y => { for (const z of\n[2]) {} })",
7267        ] {
7268            assert!(should_prepend_return(code), "must wrap: {code:?}");
7269        }
7270    }
7271
7272    #[test]
7273    fn line_terminators_and_number_dots_are_recognized() {
7274        for (s, at, len) in [
7275            ("a\nb", 1, 1),
7276            ("a\rb", 1, 1),
7277            ("a\r\nb", 1, 2),
7278            ("a\u{2028}b", 1, 3),
7279            ("a\u{2029}b", 1, 3),
7280            ("a b", 1, 0),
7281            ("a\u{2027}b", 1, 0),
7282        ] {
7283            assert_eq!(line_terminator_len(s.as_bytes(), at), len, "{s:?}");
7284        }
7285        for (code, complete) in [
7286            ("1.", true),
7287            ("x = 10.", true),
7288            ("1_000.", true),
7289            ("a1.", false),
7290            ("x.", false),
7291            ("0x1.", false),
7292            ("1e3.", false),
7293            ("1n.", false),
7294            ("1.5.", false),
7295            ("(1).", false),
7296            ("1 .", false),
7297        ] {
7298            assert_eq!(
7299                dot_completes_number(code, code.len() - 1),
7300                complete,
7301                "{code:?}"
7302            );
7303        }
7304    }
7305
7306    /// Run each `(code, expected_return, expected_log)` through the REAL eval wrapper shape
7307    /// (the code inlined in an async arrow, prepended with `return` exactly when
7308    /// [`should_prepend_return`] says so) in Node, and check every statement ran and the
7309    /// right value came back. Skips when `node` is not installed.
7310    #[test]
7311    fn prepend_return_decisions_run_every_statement_in_node() {
7312        let mut cases: Vec<(&str, &str, Vec<&str>)> = ASI_ROUND2_STATEMENT_CASES
7313            .iter()
7314            .chain(ASI_ROUND4_STATEMENT_CASES)
7315            .chain(ASI_ROUND5_STATEMENT_CASES)
7316            .map(|c| {
7317                let n = c.matches("f()").count() + c.matches("(f)()").count();
7318                (*c, "undefined", vec!["f"; n])
7319            })
7320            .collect();
7321        cases.extend([
7322            ("obj.return", "\"RET\"", vec![]),
7323            ("obj.of", "\"OF\"", vec![]),
7324            ("obj.in\n.length", "2", vec![]),
7325            ("i++ / 2", "0.5", vec![]),
7326            ("`a${'`'}b`", "\"a`b\"", vec![]),
7327            ("f() <!-- trailing html comment", "1", vec!["f"]),
7328            ("f()\n+ 1", "2", vec!["f"]),
7329            ("document", "\"doc\"", vec![]),
7330            ("x +\r1", "1", vec![]),
7331            ("x /*\n*/ + 1", "1", vec![]),
7332            ("obj.of.\nlength", "2", vec![]),
7333            ("0x10.\ntoString()", "\"16\"", vec![]),
7334            ("-->x\ndocument", "\"doc\"", vec![]),
7335        ]);
7336        let bodies: Vec<String> = cases
7337            .iter()
7338            .map(|(code, _, _)| {
7339                let body = strip_leading_js_comments(code.trim());
7340                if should_prepend_return(body) {
7341                    format!("return {body}")
7342                } else {
7343                    code.trim().to_string()
7344                }
7345            })
7346            .collect();
7347        let mut file = tempfile::NamedTempFile::new().unwrap();
7348        std::io::Write::write_all(
7349            &mut file,
7350            serde_json::to_string(&bodies).unwrap().as_bytes(),
7351        )
7352        .unwrap();
7353        let runner = r"
7354            const vm = require('vm');
7355            const bodies = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'));
7356            (async () => {
7357              const out = [];
7358              for (const body of bodies) {
7359                const log = [];
7360                globalThis.__log = log;
7361                const src = '(async () => { const log = globalThis.__log; const f = () => log.push(\'f\');'
7362                  + ' let i = 1, x = 0, a, of = 4, énew = 2, étypeof = 3; const document = \'doc\';'
7363                  + ' const obj = { of: \'OF\', in: \'IN\', return: \'RET\' };\n' + body + '\n })()';
7364                let ret, err = null;
7365                try { ret = await vm.runInThisContext(src); } catch (e) { err = e.name + ': ' + e.message; }
7366                out.push({ ret: ret === undefined ? 'undefined' : JSON.stringify(ret), log, err });
7367              }
7368              console.log('ASI_RESULTS:' + JSON.stringify(out));
7369            })();
7370        ";
7371        let Ok(output) = std::process::Command::new("node")
7372            .arg("-e")
7373            .arg(runner)
7374            .arg(file.path())
7375            .output()
7376        else {
7377            eprintln!("SKIP: node not installed");
7378            return;
7379        };
7380        let stdout = String::from_utf8_lossy(&output.stdout);
7381        let line = stdout
7382            .lines()
7383            .find_map(|l| l.strip_prefix("ASI_RESULTS:"))
7384            .unwrap_or_else(|| {
7385                panic!(
7386                    "no results: {stdout}\n{}",
7387                    String::from_utf8_lossy(&output.stderr)
7388                )
7389            });
7390        let results: Vec<serde_json::Value> = serde_json::from_str(line).unwrap();
7391        let mut failures = Vec::new();
7392        for (((code, want_ret, want_log), body), got) in cases.iter().zip(&bodies).zip(&results) {
7393            let got_log: Vec<&str> = got["log"]
7394                .as_array()
7395                .unwrap()
7396                .iter()
7397                .filter_map(|v| v.as_str())
7398                .collect();
7399            if !got["err"].is_null() || got["ret"] != *want_ret || got_log != *want_log {
7400                failures.push(format!(
7401                    "{code:?} (ran as {body:?}): got ret={} log={got_log:?} err={}; want ret={want_ret} log={want_log:?}",
7402                    got["ret"], got["err"]
7403                ));
7404            }
7405        }
7406        assert!(failures.is_empty(), "\n{}", failures.join("\n"));
7407    }
7408
7409    #[test]
7410    fn prepend_return_sees_past_leading_comments() {
7411        assert!(!should_prepend_return("// setup\nconst x = 1; x"));
7412        assert!(!should_prepend_return("/* c */ if (a) b()"));
7413        assert!(should_prepend_return("// read it\ndocument.title"));
7414        assert_eq!(
7415            strip_leading_js_comments("// a\n/* b */  document.title"),
7416            "document.title"
7417        );
7418        assert_eq!(strip_leading_js_comments("// only a comment"), "");
7419    }
7420
7421    #[test]
7422    fn prepend_return_bare_expressions() {
7423        assert!(should_prepend_return("document.title"));
7424        assert!(should_prepend_return("5 + 5"));
7425        assert!(should_prepend_return("\"justexpr\""));
7426        assert!(should_prepend_return("await fetch('/x')"));
7427        assert!(should_prepend_return(
7428            "document.querySelectorAll('a').length"
7429        ));
7430        assert!(should_prepend_return("x ? a : b"));
7431        // Single trailing semicolon on a bare expression is still an expression.
7432        assert!(should_prepend_return("document.title;"));
7433        // Semicolons inside strings must not be treated as boundaries.
7434        assert!(should_prepend_return("'a;b;c'"));
7435        assert!(should_prepend_return("\"x;y\".length"));
7436        // IIFE workaround: the `;` lives inside the arrow body (depth > 0).
7437        assert!(should_prepend_return("(()=>{window.x=5; return 'ok'})()"));
7438    }
7439
7440    #[test]
7441    fn no_prepend_for_statement_blocks() {
7442        // The original silent-corruption cases.
7443        assert!(!should_prepend_return(
7444            "localStorage.setItem('k','v'); return localStorage.getItem('k')"
7445        ));
7446        assert!(!should_prepend_return(
7447            "window.scrollTo(0,50); return window.scrollY"
7448        ));
7449        assert!(!should_prepend_return("console.log('x'); return 123"));
7450        assert!(!should_prepend_return("window.__z=7; return 'ok'"));
7451        // Explicit return without a preceding semicolon (newline-separated).
7452        assert!(!should_prepend_return("window.x = 5\nreturn window.x"));
7453    }
7454
7455    #[test]
7456    fn no_prepend_for_statement_keywords() {
7457        assert!(!should_prepend_return("return 42"));
7458        assert!(!should_prepend_return("const x = 1; return x"));
7459        assert!(!should_prepend_return("let y = 2"));
7460        assert!(!should_prepend_return("var z = 3"));
7461        assert!(!should_prepend_return("if (x) { return 1 }"));
7462        assert!(!should_prepend_return("for (const x of y) doThing(x)"));
7463        assert!(!should_prepend_return("throw new Error('x')"));
7464        assert!(!should_prepend_return("function f(){}"));
7465        assert!(!should_prepend_return("{ a: 1 }")); // object-literal-as-block ambiguity → as-is
7466    }
7467
7468    #[test]
7469    fn empty_code_no_prepend() {
7470        assert!(!should_prepend_return(""));
7471        assert!(!should_prepend_return("   "));
7472    }
7473
7474    #[test]
7475    fn envelope_unwrap_value() {
7476        assert_eq!(
7477            unwrap_eval_envelope(r#"{"__victauri_ok":"4DA","__victauri_type":"value"}"#.into()),
7478            Ok("\"4DA\"".to_string())
7479        );
7480        assert_eq!(
7481            unwrap_eval_envelope(r#"{"__victauri_ok":42,"__victauri_type":"value"}"#.into()),
7482            Ok("42".to_string())
7483        );
7484    }
7485
7486    #[test]
7487    fn envelope_unwrap_undefined_null() {
7488        assert_eq!(
7489            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#.into()),
7490            Ok("undefined".to_string())
7491        );
7492        assert_eq!(
7493            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"null"}"#.into()),
7494            Ok("null".to_string())
7495        );
7496    }
7497
7498    #[test]
7499    fn envelope_unwrap_error() {
7500        let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into());
7501        assert!(r.unwrap_err().message.contains("boom"));
7502    }
7503
7504    #[test]
7505    fn envelope_unwrap_deeply_nested_does_not_leak() {
7506        // Build an envelope whose value is nested far deeper than serde_json's
7507        // default recursion limit (128). The full parse fails, so the slice
7508        // fallback must return the value — NOT the raw `__victauri_ok` envelope.
7509        let mut value = String::from("0");
7510        for _ in 0..300 {
7511            value = format!("{{\"n\":{value}}}");
7512        }
7513        let raw = format!(r#"{{"__victauri_ok":{value},"__victauri_type":"value"}}"#);
7514        let out = unwrap_eval_envelope(raw).unwrap();
7515        assert!(
7516            out.starts_with(r#"{"n":"#),
7517            "deep value should be unwrapped, got: {}",
7518            &out[..out.len().min(40)]
7519        );
7520        assert!(
7521            !out.contains("__victauri_ok"),
7522            "envelope must not leak into the result"
7523        );
7524    }
7525
7526    #[test]
7527    fn js_string_simple() {
7528        assert_eq!(js_string("hello"), "\"hello\"");
7529    }
7530
7531    #[test]
7532    fn js_string_single_quotes() {
7533        let result = js_string("it's a test");
7534        assert!(result.contains("it's a test"));
7535    }
7536
7537    #[test]
7538    fn js_string_double_quotes() {
7539        let result = js_string(r#"say "hello""#);
7540        assert!(result.contains(r#"\""#));
7541    }
7542
7543    #[test]
7544    fn js_string_backslashes() {
7545        let result = js_string(r"path\to\file");
7546        assert!(result.contains(r"\\"));
7547    }
7548
7549    #[test]
7550    fn js_string_newlines_and_tabs() {
7551        let result = js_string("line1\nline2\ttab");
7552        assert!(result.contains(r"\n"));
7553        assert!(result.contains(r"\t"));
7554        assert!(!result.contains('\n'));
7555    }
7556
7557    #[test]
7558    fn js_string_null_bytes() {
7559        let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
7560        let result = js_string(&input);
7561        // serde_json escapes null bytes as
7562        assert!(result.contains("\\u0000"));
7563        assert!(!result.contains('\0'));
7564    }
7565
7566    #[test]
7567    fn js_string_template_literal_injection() {
7568        let result = js_string("`${alert(1)}`");
7569        // Should not contain unescaped backticks that could break template literals
7570        // serde_json wraps in double quotes, so backticks are safe
7571        assert!(result.starts_with('"'));
7572        assert!(result.ends_with('"'));
7573    }
7574
7575    #[test]
7576    fn js_string_unicode_separators() {
7577        // U+2028 (Line Separator) and U+2029 (Paragraph Separator) are valid in
7578        // JSON strings per RFC 8259, and serde_json passes them through literally.
7579        // Since js_string is used inside JS double-quoted strings (not template
7580        // literals), they are safe in modern JS engines (ES2019+).
7581        let result = js_string("a\u{2028}b\u{2029}c");
7582        // Verify the string is valid JSON that round-trips correctly
7583        let decoded: String = serde_json::from_str(&result).unwrap();
7584        assert_eq!(decoded, "a\u{2028}b\u{2029}c");
7585    }
7586
7587    #[test]
7588    fn js_string_empty() {
7589        assert_eq!(js_string(""), "\"\"");
7590    }
7591
7592    #[test]
7593    fn js_string_html_script_close() {
7594        // </script> in a JS string inside HTML could break out of script tags
7595        let result = js_string("</script><img onerror=alert(1)>");
7596        assert!(result.starts_with('"'));
7597        // The string is JSON-encoded; verify it round-trips safely
7598        let decoded: String = serde_json::from_str(&result).unwrap();
7599        assert_eq!(decoded, "</script><img onerror=alert(1)>");
7600    }
7601
7602    #[test]
7603    fn js_string_very_long() {
7604        let long = "a".repeat(100_000);
7605        let result = js_string(&long);
7606        assert!(result.len() >= 100_002); // quotes + content
7607    }
7608
7609    // ── URL validation tests ────────────────────────────────────────────────
7610
7611    #[test]
7612    fn url_allows_http() {
7613        assert!(validate_url("http://example.com", false).is_ok());
7614    }
7615
7616    #[test]
7617    fn url_allows_https() {
7618        assert!(validate_url("https://example.com/path?q=1", false).is_ok());
7619    }
7620
7621    #[test]
7622    fn url_allows_http_localhost() {
7623        assert!(validate_url("http://localhost:3000", false).is_ok());
7624    }
7625
7626    #[test]
7627    fn url_blocks_file_by_default() {
7628        let err = validate_url("file:///etc/passwd", false).unwrap_err();
7629        assert!(err.contains("file"), "error should mention the file scheme");
7630    }
7631
7632    #[test]
7633    fn url_allows_file_when_opted_in() {
7634        assert!(validate_url("file:///tmp/test.html", true).is_ok());
7635    }
7636
7637    #[test]
7638    fn url_blocks_javascript() {
7639        assert!(validate_url("javascript:alert(1)", false).is_err());
7640    }
7641
7642    #[test]
7643    fn url_blocks_javascript_case_insensitive() {
7644        assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
7645    }
7646
7647    #[test]
7648    fn url_blocks_data_scheme() {
7649        assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
7650    }
7651
7652    #[test]
7653    fn url_blocks_vbscript() {
7654        assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
7655    }
7656
7657    #[test]
7658    fn url_rejects_invalid() {
7659        assert!(validate_url("not a url at all", false).is_err());
7660    }
7661
7662    #[test]
7663    fn url_strips_control_chars() {
7664        // Control characters should be stripped, leaving a valid URL
7665        let input = format!("http://example{}com", '\0');
7666        assert!(validate_url(&input, false).is_ok());
7667    }
7668
7669    // ── CSS color sanitization tests ───────────────────────────────────────
7670
7671    #[test]
7672    fn css_color_valid_hex() {
7673        assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
7674        assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
7675        assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
7676    }
7677
7678    #[test]
7679    fn css_color_valid_rgb() {
7680        assert_eq!(
7681            sanitize_css_color("rgb(255, 0, 0)").unwrap(),
7682            "rgb(255, 0, 0)"
7683        );
7684        assert_eq!(
7685            sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
7686            "rgba(0, 0, 0, 0.5)"
7687        );
7688    }
7689
7690    #[test]
7691    fn css_color_valid_named() {
7692        assert_eq!(sanitize_css_color("red").unwrap(), "red");
7693        assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
7694    }
7695
7696    #[test]
7697    fn css_color_valid_hsl() {
7698        assert_eq!(
7699            sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
7700            "hsl(120, 50%, 50%)"
7701        );
7702    }
7703
7704    #[test]
7705    fn css_color_rejects_too_long() {
7706        let long = "a".repeat(101);
7707        assert!(sanitize_css_color(&long).is_err());
7708    }
7709
7710    #[test]
7711    fn css_color_rejects_backslash_escapes() {
7712        assert!(sanitize_css_color(r"red\00").is_err());
7713        assert!(sanitize_css_color(r"\72\65\64").is_err());
7714    }
7715
7716    #[test]
7717    fn css_color_rejects_url_injection() {
7718        assert!(sanitize_css_color("url(http://evil.com)").is_err());
7719        assert!(sanitize_css_color("URL(http://evil.com)").is_err());
7720    }
7721
7722    #[test]
7723    fn css_color_rejects_expression_injection() {
7724        assert!(sanitize_css_color("expression(alert(1))").is_err());
7725        assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
7726    }
7727
7728    #[test]
7729    fn css_color_rejects_import() {
7730        assert!(sanitize_css_color("@import url(evil.css)").is_err());
7731    }
7732
7733    #[test]
7734    fn css_color_rejects_semicolons_and_braces() {
7735        assert!(sanitize_css_color("red; background: url(evil)").is_err());
7736        assert!(sanitize_css_color("red} body { color: blue").is_err());
7737    }
7738
7739    #[test]
7740    fn css_color_rejects_special_chars() {
7741        assert!(sanitize_css_color("red<script>").is_err());
7742        assert!(sanitize_css_color("red\"onload=alert").is_err());
7743        assert!(sanitize_css_color("red'onclick=alert").is_err());
7744    }
7745
7746    #[test]
7747    fn css_color_trims_whitespace() {
7748        assert_eq!(sanitize_css_color("  red  ").unwrap(), "red");
7749    }
7750
7751    #[test]
7752    fn css_color_empty_string() {
7753        assert_eq!(sanitize_css_color("").unwrap(), "");
7754    }
7755}
7756
7757/// Dispatch-level authorization tests.
7758///
7759/// These exercise the REAL `execute_tool` dispatch path (not just the privacy
7760/// string matrix) to prove that blocked tools/actions actually return
7761/// `tool_disabled` and never reach their handler. This is the negative security
7762/// suite the audit required (Gate #5): the prior tests validated
7763/// `is_tool_enabled(...)` in isolation, which let structural dispatch bypasses
7764/// pass undetected.
7765#[cfg(test)]
7766mod authz_dispatch_tests {
7767    use super::*;
7768    use crate::bridge::WebviewBridge;
7769    use crate::privacy::PrivacyConfig;
7770    use std::collections::{HashMap, HashSet};
7771    use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
7772
7773    /// A bridge whose eval always fails immediately, so an *allowed* action that
7774    /// reaches the bridge returns a non-privacy error fast (no 30s hang), while a
7775    /// *blocked* action is rejected by dispatch before the bridge is ever touched.
7776    struct RejectingBridge;
7777
7778    impl WebviewBridge for RejectingBridge {
7779        fn eval_webview(&self, _label: Option<&str>, _script: &str) -> Result<(), String> {
7780            Err("eval rejected in authz dispatch test".to_string())
7781        }
7782        fn get_window_states(&self, _label: Option<&str>) -> Vec<WindowState> {
7783            Vec::new()
7784        }
7785        fn list_window_labels(&self) -> Vec<String> {
7786            Vec::new()
7787        }
7788        fn get_native_handle(&self, _label: Option<&str>) -> Result<isize, String> {
7789            Err("no handle".to_string())
7790        }
7791        fn manage_window(&self, _label: Option<&str>, _action: &str) -> Result<String, String> {
7792            Err("no window".to_string())
7793        }
7794        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
7795            Ok(())
7796        }
7797        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
7798            Ok(())
7799        }
7800        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
7801            Ok(())
7802        }
7803    }
7804
7805    fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
7806        Arc::new(VictauriState {
7807            event_log: EventLog::new(1000),
7808            registry: CommandRegistry::new(),
7809            port: std::sync::atomic::AtomicU16::new(0),
7810            pending_evals: Arc::new(Mutex::new(HashMap::new())),
7811            recorder: EventRecorder::new(1000),
7812            privacy,
7813            eval_timeout: std::time::Duration::from_millis(100),
7814            shutdown_tx: tokio::sync::watch::channel(false).0,
7815            started_at: std::time::Instant::now(),
7816            tool_invocations: std::sync::atomic::AtomicU64::new(0),
7817            allow_file_navigation: false,
7818            command_timings: crate::introspection::CommandTimings::new(),
7819            fault_registry: crate::introspection::FaultRegistry::new(),
7820            contract_store: crate::introspection::ContractStore::new(),
7821            startup_timeline: crate::introspection::StartupTimeline::new(),
7822            event_bus: crate::introspection::EventBusMonitor::default(),
7823            task_tracker: crate::introspection::TaskTracker::new(),
7824            bridge_ready: std::sync::atomic::AtomicBool::new(true),
7825            bridge_notify: tokio::sync::Notify::new(),
7826            db_search_paths: Vec::new(),
7827            screencast: Arc::new(crate::screencast::Screencast::default()),
7828            probes: crate::introspection::AppStateProbes::default(),
7829            drain_watermarks: crate::introspection::DrainWatermarks::default(),
7830            page_loads: crate::introspection::PageLoads::default(),
7831            server_runtime: std::sync::OnceLock::new(),
7832        })
7833    }
7834
7835    fn handler(privacy: PrivacyConfig) -> VictauriMcpHandler {
7836        VictauriMcpHandler::new(state_with(privacy), Arc::new(RejectingBridge))
7837    }
7838
7839    /// True iff the result is a privacy/authorization block (vs any other error).
7840    fn is_privacy_blocked(r: &CallToolResult) -> bool {
7841        r.is_error == Some(true)
7842            && r.content.iter().any(|c| {
7843                matches!(c, ContentBlock::Text(t)
7844                    if t.text.contains("disabled by privacy configuration"))
7845            })
7846    }
7847
7848    async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
7849        match h.execute_tool(tool, args).await {
7850            Ok(r) => r,
7851            Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
7852        }
7853    }
7854
7855    /// The action strings a params type's `action` enum accepts, from its JSON schema.
7856    fn schema_actions<T: schemars::JsonSchema>() -> Vec<String> {
7857        fn collect(v: &serde_json::Value, out: &mut Vec<String>) {
7858            if let Some(values) = v.get("enum").and_then(serde_json::Value::as_array) {
7859                out.extend(values.iter().filter_map(|x| x.as_str().map(String::from)));
7860            }
7861            if let Some(c) = v.get("const").and_then(serde_json::Value::as_str) {
7862                out.push(c.to_string());
7863            }
7864            for key in ["oneOf", "anyOf"] {
7865                for sub in v
7866                    .get(key)
7867                    .and_then(serde_json::Value::as_array)
7868                    .into_iter()
7869                    .flatten()
7870                {
7871                    collect(sub, out);
7872                }
7873            }
7874        }
7875        let schema = serde_json::to_value(schemars::schema_for!(T)).unwrap();
7876        let mut action = schema["properties"]["action"].clone();
7877        if let Some(target) = action.get("$ref").and_then(serde_json::Value::as_str) {
7878            let name = target.rsplit('/').next().unwrap();
7879            action = schema["$defs"][name].clone();
7880        }
7881        let mut out = Vec::new();
7882        collect(&action, &mut out);
7883        out
7884    }
7885
7886    /// `resolve_capability` gates an unknown STRING action as the bare tool name,
7887    /// trusting the typed parse to reject it. That holds only if every action the parse
7888    /// ACCEPTS is mapped to its own capability — pinned here from the enums themselves,
7889    /// so a new variant cannot silently fall back to the bare-name gate.
7890    #[test]
7891    fn every_action_variant_has_a_capability() {
7892        let tools: &[(&str, Vec<String>)] = &[
7893            ("interact", schema_actions::<InteractParams>()),
7894            ("input", schema_actions::<InputParams>()),
7895            ("window", schema_actions::<WindowParams>()),
7896            ("storage", schema_actions::<StorageParams>()),
7897            ("navigate", schema_actions::<NavigateParams>()),
7898            ("recording", schema_actions::<RecordingParams>()),
7899            ("inspect", schema_actions::<InspectParams>()),
7900            ("css", schema_actions::<CssParams>()),
7901            ("route", schema_actions::<RouteParams>()),
7902            ("trace", schema_actions::<TraceParams>()),
7903            ("animation", schema_actions::<AnimationParams>()),
7904            ("logs", schema_actions::<LogsParams>()),
7905            ("introspect", schema_actions::<IntrospectParams>()),
7906            ("fault", schema_actions::<FaultParams>()),
7907            ("explain", schema_actions::<ExplainParams>()),
7908        ];
7909        for (tool, actions) in tools {
7910            assert!(
7911                !actions.is_empty(),
7912                "{tool}: no actions read from its schema"
7913            );
7914            for action in actions {
7915                assert!(
7916                    authz::action_capability(tool, action).is_some(),
7917                    "{tool}.{action} is accepted by the parser but has no capability"
7918                );
7919            }
7920        }
7921    }
7922
7923    /// The names `disable_tools` validates against are the live tool surface: every
7924    /// registered tool is either a compound tool or in `STANDALONE_TOOLS`, and nothing else.
7925    #[test]
7926    fn disable_tools_name_list_matches_the_live_tools() {
7927        let mut live: Vec<String> = VictauriMcpHandler::tool_router()
7928            .list_all()
7929            .into_iter()
7930            .map(|t| t.name.to_string())
7931            .filter(|n| !authz::is_compound_tool(n))
7932            .collect();
7933        let mut listed: Vec<String> = authz::STANDALONE_TOOLS
7934            .iter()
7935            .map(|s| (*s).to_string())
7936            .collect();
7937        live.sort_unstable();
7938        listed.sort_unstable();
7939        assert_eq!(live, listed);
7940        for t in VictauriMcpHandler::tool_router().list_all() {
7941            assert!(authz::is_known_name(&t.name), "{}", t.name);
7942        }
7943    }
7944
7945    /// Audit N1: `{"action": {"go_to": null}}` (a tag-shaped enum serde accepts) and a
7946    /// positional array body used to be gated as the bare tool name, which the Test
7947    /// profile allows for `navigate` — the handler then parsed and ran `go_to`. Both
7948    /// shapes must now be refused as invalid params before any handler runs, in every
7949    /// profile (`FullControl` with the action disabled is the other half of the bypass).
7950    #[tokio::test]
7951    async fn non_string_action_cannot_slip_past_the_gate() {
7952        let mut full_minus_go_to = PrivacyConfig::default();
7953        full_minus_go_to
7954            .disabled_tools
7955            .insert("navigate.go_to".to_string());
7956        for privacy in [crate::privacy::test_privacy_config(), full_minus_go_to] {
7957            let h = handler(privacy);
7958            for args in [
7959                serde_json::json!({"action": {"go_to": null}, "url": "https://evil.example"}),
7960                serde_json::json!(["go_to", "https://evil.example", null, null, null, null]),
7961                serde_json::json!({"action": 0, "url": "https://evil.example"}),
7962            ] {
7963                match h.execute_tool("navigate", args.clone()).await {
7964                    Err(rest::ToolCallError::InvalidParams(_)) => {}
7965                    Ok(r) => panic!("{args} reached dispatch: {:?}", r.content),
7966                    Err(rest::ToolCallError::UnknownTool(t)) => panic!("{args}: unknown tool {t}"),
7967                }
7968            }
7969        }
7970    }
7971
7972    // ── Observe profile: every mutation/eval/compound-action must be blocked ──
7973
7974    #[tokio::test]
7975    async fn observe_blocks_mutations_and_eval_through_dispatch() {
7976        let h = handler(crate::privacy::observe_privacy_config());
7977        let blocked: &[(&str, serde_json::Value)] = &[
7978            ("eval_js", serde_json::json!({"code": "1"})),
7979            (
7980                "wait_for",
7981                serde_json::json!({"condition": "expression", "value": "true"}),
7982            ),
7983            ("screenshot", serde_json::json!({})),
7984            ("invoke_command", serde_json::json!({"command": "greet"})),
7985            ("verify_state", serde_json::json!({"frontend_expr": "1"})),
7986            (
7987                "assert_semantic",
7988                serde_json::json!({"expression": "1", "condition": "truthy"}),
7989            ),
7990            (
7991                "interact",
7992                serde_json::json!({"action": "click", "ref_id": "e1"}),
7993            ),
7994            (
7995                "input",
7996                serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
7997            ),
7998            (
7999                "storage",
8000                serde_json::json!({"action": "set", "key": "k", "value": "v"}),
8001            ),
8002            (
8003                "storage",
8004                serde_json::json!({"action": "delete", "key": "k"}),
8005            ),
8006            (
8007                "window",
8008                serde_json::json!({"action": "manage", "manage_action": "close"}),
8009            ),
8010            (
8011                "window",
8012                serde_json::json!({"action": "set_title", "title": "x"}),
8013            ),
8014            (
8015                "navigate",
8016                serde_json::json!({"action": "go_to", "url": "https://e.com"}),
8017            ),
8018            (
8019                "css",
8020                serde_json::json!({"action": "inject", "css": "body{}"}),
8021            ),
8022            ("route", serde_json::json!({"action": "clear_all"})),
8023            ("recording", serde_json::json!({"action": "start"})),
8024            ("recording", serde_json::json!({"action": "replay"})),
8025            ("logs", serde_json::json!({"action": "clear"})),
8026            (
8027                "fault",
8028                serde_json::json!({"action": "inject", "command": "x", "fault_type": "error"}),
8029            ),
8030            (
8031                "introspect",
8032                serde_json::json!({"action": "command_timings"}),
8033            ),
8034        ];
8035        for (tool, args) in blocked {
8036            let r = call(&h, tool, args.clone()).await;
8037            assert!(
8038                is_privacy_blocked(&r),
8039                "Observe must block {tool} {args} at dispatch, got: {:?}",
8040                r.content
8041            );
8042        }
8043    }
8044
8045    #[tokio::test]
8046    async fn observe_allows_read_only_through_dispatch() {
8047        let h = handler(crate::privacy::observe_privacy_config());
8048        // These reads must NOT be privacy-blocked (they may fail for other reasons
8049        // against the rejecting bridge, but never with a privacy block).
8050        let allowed: &[(&str, serde_json::Value)] = &[
8051            ("get_registry", serde_json::json!({})),
8052            ("get_memory_stats", serde_json::json!({})),
8053            ("window", serde_json::json!({"action": "list"})),
8054            ("logs", serde_json::json!({"action": "ipc"})),
8055            (
8056                "inspect",
8057                serde_json::json!({"action": "get_styles", "ref_id": "e1"}),
8058            ),
8059        ];
8060        for (tool, args) in allowed {
8061            let r = call(&h, tool, args.clone()).await;
8062            assert!(
8063                !is_privacy_blocked(&r),
8064                "Observe must allow {tool} {args} at dispatch (blocked unexpectedly)"
8065            );
8066        }
8067    }
8068
8069    // ── Test profile: interactions allowed, eval/replay/route blocked ─────────
8070
8071    #[tokio::test]
8072    async fn test_profile_dispatch_boundaries() {
8073        let h = handler(crate::privacy::test_privacy_config());
8074        // Allowed in Test:
8075        for (tool, args) in [
8076            (
8077                "interact",
8078                serde_json::json!({"action": "click", "ref_id": "e1"}),
8079            ),
8080            (
8081                "input",
8082                serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
8083            ),
8084            (
8085                "storage",
8086                serde_json::json!({"action": "set", "key": "k", "value": "v"}),
8087            ),
8088            ("navigate", serde_json::json!({"action": "go_back"})),
8089            ("recording", serde_json::json!({"action": "start"})),
8090            ("logs", serde_json::json!({"action": "clear"})),
8091        ] {
8092            let r = call(&h, tool, args.clone()).await;
8093            assert!(!is_privacy_blocked(&r), "Test must allow {tool} {args}");
8094        }
8095        // Blocked in Test (arbitrary eval, navigation mutation, replay, FullControl tools):
8096        for (tool, args) in [
8097            ("eval_js", serde_json::json!({"code": "1"})),
8098            (
8099                "wait_for",
8100                serde_json::json!({"condition": "expression", "value": "true"}),
8101            ),
8102            ("verify_state", serde_json::json!({"frontend_expr": "1"})),
8103            (
8104                "navigate",
8105                serde_json::json!({"action": "go_to", "url": "https://e.com"}),
8106            ),
8107            ("recording", serde_json::json!({"action": "replay"})),
8108            (
8109                "route",
8110                serde_json::json!({"action": "add", "pattern": "x"}),
8111            ),
8112            ("css", serde_json::json!({"action": "inject", "css": "x"})),
8113            (
8114                "window",
8115                serde_json::json!({"action": "set_title", "title": "x"}),
8116            ),
8117        ] {
8118            let r = call(&h, tool, args.clone()).await;
8119            assert!(is_privacy_blocked(&r), "Test must block {tool} {args}");
8120        }
8121    }
8122
8123    // ── disabled_tools: bare-name disable covers all of a compound tool's
8124    //    actions, and per-action disable is honored even when the handler
8125    //    historically did not check it (the route.clear bypass). ──────────────
8126
8127    #[tokio::test]
8128    async fn disabling_bare_compound_tool_blocks_all_actions() {
8129        let cfg = PrivacyConfig {
8130            disabled_tools: HashSet::from(["recording".to_string()]),
8131            ..Default::default()
8132        }; // FullControl with the whole `recording` tool disabled
8133        let h = handler(cfg);
8134        for action in ["start", "stop", "replay", "import", "export"] {
8135            let r = call(&h, "recording", serde_json::json!({"action": action})).await;
8136            assert!(
8137                is_privacy_blocked(&r),
8138                "disabling bare `recording` must block recording.{action}"
8139            );
8140        }
8141    }
8142
8143    #[tokio::test]
8144    async fn disabling_specific_action_is_honored_at_dispatch() {
8145        // The historical bypass: `route.clear`'s handler had no per-action check,
8146        // so a `disabled_tools` entry for it was silently ignored. The central
8147        // gate now enforces it.
8148        let cfg = PrivacyConfig {
8149            disabled_tools: HashSet::from([
8150                "route.clear".to_string(),
8151                "route.clear_all".to_string(),
8152            ]),
8153            ..Default::default()
8154        }; // FullControl: everything else allowed
8155        let h = handler(cfg);
8156
8157        let blocked = call(&h, "route", serde_json::json!({"action": "clear", "id": 1})).await;
8158        assert!(is_privacy_blocked(&blocked), "route.clear must be blocked");
8159        let blocked_all = call(&h, "route", serde_json::json!({"action": "clear_all"})).await;
8160        assert!(
8161            is_privacy_blocked(&blocked_all),
8162            "route.clear_all must be blocked"
8163        );
8164
8165        // A sibling action the operator did NOT disable is still reachable.
8166        let allowed = call(&h, "route", serde_json::json!({"action": "list"})).await;
8167        assert!(
8168            !is_privacy_blocked(&allowed),
8169            "route.list must remain allowed"
8170        );
8171    }
8172
8173    /// R4-NET2: `animation scrub` with `capture=true` takes native window screenshots, so an
8174    /// operator who disabled `screenshot` must not get pixels through it (`trace` already
8175    /// required both). Without capture, scrub is a plain page read and stays allowed.
8176    #[tokio::test]
8177    async fn animation_scrub_capture_requires_the_screenshot_tool() {
8178        let cfg = PrivacyConfig {
8179            disabled_tools: HashSet::from(["screenshot".to_string()]),
8180            ..Default::default()
8181        };
8182        let h = handler(cfg);
8183        let r = call(
8184            &h,
8185            "animation",
8186            serde_json::json!({"action": "scrub", "selector": "#toast", "capture": true}),
8187        )
8188        .await;
8189        assert!(
8190            is_privacy_blocked(&r),
8191            "scrub capture must be refused while `screenshot` is disabled, got: {:?}",
8192            r.content
8193        );
8194        assert!(
8195            r.content
8196                .iter()
8197                .any(|c| matches!(c, ContentBlock::Text(t) if t.text.contains("screenshot"))),
8198            "the refusal must name the screenshot tool: {:?}",
8199            r.content
8200        );
8201        let r = call(
8202            &h,
8203            "animation",
8204            serde_json::json!({"action": "scrub", "selector": "#toast"}),
8205        )
8206        .await;
8207        assert!(!is_privacy_blocked(&r), "scrub without capture is allowed");
8208    }
8209
8210    /// R4-NET4: the action spelling of an `inspect` capability disables it at dispatch.
8211    #[tokio::test]
8212    async fn disabling_an_inspect_action_by_its_action_name_is_honored() {
8213        let cfg = PrivacyConfig {
8214            disabled_tools: HashSet::from(["inspect.get_styles".to_string()]),
8215            ..Default::default()
8216        };
8217        let h = handler(cfg);
8218        let r = call(
8219            &h,
8220            "inspect",
8221            serde_json::json!({"action": "get_styles", "ref_id": "e1"}),
8222        )
8223        .await;
8224        assert!(
8225            is_privacy_blocked(&r),
8226            "inspect.get_styles must be blocked, got: {:?}",
8227            r.content
8228        );
8229        let r = call(
8230            &h,
8231            "inspect",
8232            serde_json::json!({"action": "get_bounding_boxes", "ref_ids": ["e1"]}),
8233        )
8234        .await;
8235        assert!(!is_privacy_blocked(&r), "a sibling action stays allowed");
8236    }
8237
8238    // Command-policy enforcement on invoke paths (A1/A2) and resource gating (B1)
8239    // are covered with side-effect detection (a bridge that records actual invokes)
8240    // in the `command_policy_dispatch_tests` module below — that proves the blocked
8241    // command never reaches the bridge, not merely that an error string is returned.
8242
8243    #[tokio::test]
8244    async fn full_control_allows_everything_at_dispatch() {
8245        let h = handler(PrivacyConfig::default());
8246        for (tool, args) in [
8247            ("recording", serde_json::json!({"action": "replay"})),
8248            ("route", serde_json::json!({"action": "clear_all"})),
8249            ("eval_js", serde_json::json!({"code": "1"})),
8250            ("fault", serde_json::json!({"action": "list"})),
8251        ] {
8252            let r = call(&h, tool, args.clone()).await;
8253            assert!(
8254                !is_privacy_blocked(&r),
8255                "FullControl must allow {tool} {args}"
8256            );
8257        }
8258    }
8259}
8260
8261/// Command-policy enforcement on EVERY command-invoking path (audit #30/#31, triage A1/A2).
8262///
8263/// The prior privacy suite validated the permission-string matrix — `is_tool_enabled("x")`
8264/// in isolation — which let structural dispatch bypasses pass undetected (the audit's
8265/// central criticism: "tests validate the STRING MATRIX, not actual dispatch behavior").
8266///
8267/// These tests instead drive the REAL dispatcher with a bridge that records every script
8268/// handed to `eval_webview`, and assert the dangerous **side effect** — the
8269/// `__TAURI_INTERNALS__.invoke(<command>)` script — is NEVER emitted when the command is on
8270/// the operator's blocklist, on each path that invokes commands OUTSIDE `invoke_command`:
8271/// `recording.replay`, `recording.import` + `replay`, `introspect.contract_record`, and
8272/// `introspect.contract_check`. Each has a positive control proving an *allowed* command IS
8273/// invoked (so a blanket-block can't make the negative test pass vacuously).
8274#[cfg(test)]
8275mod command_policy_dispatch_tests {
8276    use super::*;
8277    use crate::bridge::WebviewBridge;
8278    use crate::privacy::PrivacyConfig;
8279    use serde_json::json;
8280    use std::collections::{HashMap, HashSet};
8281    use std::sync::Mutex as StdMutex;
8282    use victauri_core::{
8283        AppEvent, CommandRegistry, EventLog, EventRecorder, IpcCall, IpcResult, RecordedEvent,
8284        RecordedSession, WindowState,
8285    };
8286
8287    /// A bridge that RECORDS every script passed to `eval_webview` (so a test can assert a
8288    /// blocklisted command's invoke was never emitted) then fails the eval fast — an allowed
8289    /// command is observably *attempted* without hanging on a callback that never arrives.
8290    ///
8291    /// When constructed via [`RecordingBridge::answering`] it also resolves the pre-eval
8292    /// liveness probe, simulating a healthy webview so an ALLOWED command's invoke actually
8293    /// reaches the bridge. Default-constructed bridges leave the probe unanswered — which is
8294    /// fine for negative tests, since a blocked command is rejected at the privacy gate
8295    /// *before* any eval (and thus never probes).
8296    #[derive(Clone, Default)]
8297    struct RecordingBridge {
8298        scripts: Arc<StdMutex<Vec<String>>>,
8299        pending_evals: Option<crate::PendingCallbacks>,
8300        /// The nonce of the page currently loaded (reported by the liveness probe).
8301        page_nonce: Arc<StdMutex<Option<String>>>,
8302        /// When set, the eval wrapper script is answered with this callback body.
8303        eval_answer: Arc<StdMutex<Option<String>>>,
8304        /// How long after the wrapper is delivered its answer arrives (code that takes a while).
8305        eval_answer_delay: Arc<StdMutex<std::time::Duration>>,
8306        /// Every trusted (OS-level) input delivered, e.g. `click 50,26` / `type hi` / `key Enter`.
8307        natives: Arc<StdMutex<Vec<String>>>,
8308        /// While set, the liveness probe is never answered (a busy or wedged UI thread).
8309        probe_silent: Arc<AtomicBool>,
8310    }
8311
8312    /// Extract the 36-char eval id from a probe script of the form `…id:"<uuid>"…`.
8313    fn extract_probe_id(script: &str) -> Option<String> {
8314        let start = script.find("id:\"")? + 4;
8315        script.get(start..start + 36).map(str::to_string)
8316    }
8317
8318    /// Extract the eval id from the eval wrapper script (`const __vic = { id: "<uuid>", …`).
8319    fn extract_wrapper_id(script: &str) -> Option<String> {
8320        let start = script.find("__vic = { id: \"")? + 15;
8321        script.get(start..start + 36).map(str::to_string)
8322    }
8323
8324    impl RecordingBridge {
8325        /// A recording bridge that answers the liveness probe with the state's pending-evals
8326        /// map, so a permitted command's eval proceeds past the probe and is observably
8327        /// injected.
8328        fn answering(pending_evals: crate::PendingCallbacks) -> Self {
8329            Self {
8330                pending_evals: Some(pending_evals),
8331                ..Self::default()
8332            }
8333        }
8334
8335        /// Like [`answering`](Self::answering), in a page whose nonce is `nonce`.
8336        fn in_page(pending_evals: crate::PendingCallbacks, nonce: &str) -> Self {
8337            let b = Self::answering(pending_evals);
8338            b.load_page(nonce);
8339            b
8340        }
8341
8342        /// The window now shows a page with this nonce (what the liveness probe reports).
8343        fn load_page(&self, nonce: &str) {
8344            *self
8345                .page_nonce
8346                .lock()
8347                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(nonce.to_string());
8348        }
8349
8350        fn answer_evals_with(&self, body: &str) {
8351            *self
8352                .eval_answer
8353                .lock()
8354                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(body.to_string());
8355        }
8356
8357        /// Like [`answer_evals_with`](Self::answer_evals_with), arriving `delay` after the code
8358        /// was delivered.
8359        fn answer_evals_after(&self, body: &str, delay: std::time::Duration) {
8360            self.answer_evals_with(body);
8361            *self
8362                .eval_answer_delay
8363                .lock()
8364                .unwrap_or_else(std::sync::PoisonError::into_inner) = delay;
8365        }
8366
8367        fn natives(&self) -> Vec<String> {
8368            self.natives
8369                .lock()
8370                .unwrap_or_else(std::sync::PoisonError::into_inner)
8371                .clone()
8372        }
8373
8374        fn record_native(&self, what: String) -> Result<(), String> {
8375            self.natives
8376                .lock()
8377                .unwrap_or_else(std::sync::PoisonError::into_inner)
8378                .push(what);
8379            Ok(())
8380        }
8381
8382        /// True iff any recorded eval script invoked `command` via the Tauri IPC bridge.
8383        fn invoked(&self, command: &str) -> bool {
8384            let needle = format!("invoke({}", js_string(command));
8385            self.scripts
8386                .lock()
8387                .unwrap_or_else(std::sync::PoisonError::into_inner)
8388                .iter()
8389                .any(|s| s.contains(&needle))
8390        }
8391    }
8392
8393    impl WebviewBridge for RecordingBridge {
8394        fn eval_webview(&self, _label: Option<&str>, script: &str) -> Result<(), String> {
8395            self.scripts
8396                .lock()
8397                .unwrap_or_else(std::sync::PoisonError::into_inner)
8398                .push(script.to_string());
8399            // If wired with a pending-evals map, answer the pre-eval liveness probe
8400            // (simulating a healthy webview) so the real eval proceeds past it. The
8401            // real eval is still left unanswered, so it times out fast at the 100ms
8402            // test `eval_timeout` — we only care WHICH scripts reached the bridge,
8403            // never the eval's return value.
8404            let mut delay = std::time::Duration::ZERO;
8405            let answer = if script.contains("probe_ok") {
8406                if self.probe_silent.load(Ordering::SeqCst) {
8407                    return Ok(());
8408                }
8409                extract_probe_id(script).map(|id| {
8410                    let nonce = self
8411                        .page_nonce
8412                        .lock()
8413                        .unwrap_or_else(std::sync::PoisonError::into_inner)
8414                        .clone();
8415                    let body = nonce.map_or_else(
8416                        || "\"probe_ok\"".to_string(),
8417                        |n| format!("\"probe_ok:{n}\""),
8418                    );
8419                    (id, body)
8420                })
8421            } else {
8422                let body = self
8423                    .eval_answer
8424                    .lock()
8425                    .unwrap_or_else(std::sync::PoisonError::into_inner)
8426                    .clone();
8427                delay = *self
8428                    .eval_answer_delay
8429                    .lock()
8430                    .unwrap_or_else(std::sync::PoisonError::into_inner);
8431                extract_wrapper_id(script).zip(body)
8432            };
8433            if let (Some(pending), Some((id, body))) = (&self.pending_evals, answer) {
8434                let pending = pending.clone();
8435                std::thread::spawn(move || {
8436                    std::thread::sleep(delay);
8437                    let mut map = pending.blocking_lock();
8438                    if let Some(tx) = map.remove(&id) {
8439                        let _ = tx.send(body);
8440                    }
8441                });
8442            }
8443            // Return Ok so `eval_with_return` injects BOTH its watchdog and the
8444            // user-code script (it bails on the first Err).
8445            Ok(())
8446        }
8447        fn native_click(&self, _l: Option<&str>, x: f64, y: f64) -> Result<(), String> {
8448            self.record_native(format!("click {x},{y}"))
8449        }
8450        fn native_type_text(&self, _l: Option<&str>, text: &str) -> Result<(), String> {
8451            self.record_native(format!("type {text}"))
8452        }
8453        fn native_key(&self, _l: Option<&str>, key: &str) -> Result<(), String> {
8454            self.record_native(format!("key {key}"))
8455        }
8456        fn get_window_states(&self, _l: Option<&str>) -> Vec<WindowState> {
8457            Vec::new()
8458        }
8459        fn list_window_labels(&self) -> Vec<String> {
8460            Vec::new()
8461        }
8462        fn get_native_handle(&self, _l: Option<&str>) -> Result<isize, String> {
8463            Err("no handle".to_string())
8464        }
8465        fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
8466            Err("no window".to_string())
8467        }
8468        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
8469            Ok(())
8470        }
8471        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
8472            Ok(())
8473        }
8474        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
8475            Ok(())
8476        }
8477    }
8478
8479    fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
8480        Arc::new(VictauriState {
8481            event_log: EventLog::new(1000),
8482            registry: CommandRegistry::new(),
8483            port: std::sync::atomic::AtomicU16::new(0),
8484            pending_evals: Arc::new(Mutex::new(HashMap::new())),
8485            recorder: EventRecorder::new(1000),
8486            privacy,
8487            eval_timeout: std::time::Duration::from_millis(100),
8488            shutdown_tx: tokio::sync::watch::channel(false).0,
8489            started_at: std::time::Instant::now(),
8490            tool_invocations: std::sync::atomic::AtomicU64::new(0),
8491            allow_file_navigation: false,
8492            command_timings: crate::introspection::CommandTimings::new(),
8493            fault_registry: crate::introspection::FaultRegistry::new(),
8494            contract_store: crate::introspection::ContractStore::new(),
8495            startup_timeline: crate::introspection::StartupTimeline::new(),
8496            event_bus: crate::introspection::EventBusMonitor::default(),
8497            task_tracker: crate::introspection::TaskTracker::new(),
8498            bridge_ready: std::sync::atomic::AtomicBool::new(true),
8499            bridge_notify: tokio::sync::Notify::new(),
8500            db_search_paths: Vec::new(),
8501            screencast: Arc::new(crate::screencast::Screencast::default()),
8502            probes: crate::introspection::AppStateProbes::default(),
8503            drain_watermarks: crate::introspection::DrainWatermarks::default(),
8504            page_loads: crate::introspection::PageLoads::default(),
8505            server_runtime: std::sync::OnceLock::new(),
8506        })
8507    }
8508
8509    // FullControl, except the named commands are blocklisted — exactly the scenario
8510    // the audit flagged: an operator who trusts `command_blocklist` to stop a
8511    // dangerous command.
8512    fn blocking(cmds: &[&str]) -> PrivacyConfig {
8513        PrivacyConfig {
8514            command_blocklist: cmds.iter().map(|s| (*s).to_string()).collect(),
8515            ..Default::default()
8516        }
8517    }
8518
8519    fn ipc_event(command: &str) -> AppEvent {
8520        AppEvent::Ipc(IpcCall::new(
8521            format!("c-{command}"),
8522            command.to_string(),
8523            chrono::Utc::now(),
8524            IpcResult::Ok(json!(true)),
8525            Some(1),
8526            0,
8527            "main".to_string(),
8528        ))
8529    }
8530
8531    fn result_text(r: &CallToolResult) -> String {
8532        r.content
8533            .iter()
8534            .filter_map(|c| match c {
8535                ContentBlock::Text(t) => Some(t.text.clone()),
8536                _ => None,
8537            })
8538            .collect::<Vec<_>>()
8539            .join("\n")
8540    }
8541
8542    async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
8543        match h.execute_tool(tool, args).await {
8544            Ok(r) => r,
8545            Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
8546        }
8547    }
8548
8549    // ── introspect event_bus output cap (VIC-4) ──────────────────────────────
8550    #[tokio::test]
8551    async fn event_bus_caps_output_to_limit() {
8552        // The full buffers can be tens of thousands of events (megabytes); the action must cap
8553        // output (default 100, newest first) and still report the true total + a truncated flag.
8554        use crate::introspection::CapturedTauriEvent;
8555        let state = state_with(PrivacyConfig::default());
8556        for i in 0..150 {
8557            state.event_bus.push(CapturedTauriEvent {
8558                name: format!("evt-{i}"),
8559                payload: "{}".to_string(),
8560                timestamp: chrono::Utc::now().to_rfc3339(),
8561            });
8562        }
8563        let h = VictauriMcpHandler::new(state, Arc::new(RecordingBridge::default()));
8564
8565        // Default limit (100).
8566        let r = call(&h, "introspect", json!({"action": "event_bus"})).await;
8567        let v: serde_json::Value = serde_json::from_str(&result_text(&r)).unwrap();
8568        assert_eq!(
8569            v["tauri_events"]["count"], 150,
8570            "true total must be reported"
8571        );
8572        assert_eq!(v["tauri_events"]["returned"], 100, "default cap is 100");
8573        assert_eq!(v["tauri_events"]["truncated"], true);
8574        assert_eq!(v["tauri_events"]["events"].as_array().unwrap().len(), 100);
8575
8576        // Explicit smaller limit (passed via the generic `args` object).
8577        let r = call(
8578            &h,
8579            "introspect",
8580            json!({"action": "event_bus", "args": {"limit": 10}}),
8581        )
8582        .await;
8583        let v: serde_json::Value = serde_json::from_str(&result_text(&r)).unwrap();
8584        assert_eq!(v["tauri_events"]["returned"], 10);
8585        assert_eq!(v["tauri_events"]["events"].as_array().unwrap().len(), 10);
8586    }
8587
8588    // ── recording.replay (audit #30/#31, A1) ─────────────────────────────────
8589
8590    #[tokio::test]
8591    async fn replay_never_invokes_a_blocklisted_command() {
8592        let bridge = RecordingBridge::default();
8593        let state = state_with(blocking(&["delete_account"]));
8594        state.recorder.start("s1".to_string()).unwrap();
8595        state.recorder.record_event(ipc_event("delete_account"));
8596        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8597
8598        let r = call(&h, "recording", json!({"action": "replay"})).await;
8599
8600        assert!(
8601            !bridge.invoked("delete_account"),
8602            "SIDE-EFFECT LEAK: replay handed a blocklisted command's invoke to the bridge (audit #30/#31)"
8603        );
8604        assert!(
8605            result_text(&r).contains("blocked"),
8606            "replay should report the command as blocked, got: {}",
8607            result_text(&r)
8608        );
8609    }
8610
8611    #[tokio::test]
8612    async fn replay_does_invoke_an_allowed_command() {
8613        // Positive control: proves the negative test isn't vacuous (the path really
8614        // reaches the bridge for a permitted command).
8615        let state = state_with(PrivacyConfig::default());
8616        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8617        state.recorder.start("s1".to_string()).unwrap();
8618        state.recorder.record_event(ipc_event("greet"));
8619        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8620
8621        let _ = call(&h, "recording", json!({"action": "replay"})).await;
8622
8623        assert!(
8624            bridge.invoked("greet"),
8625            "positive control failed: an ALLOWED command was not invoked, so the negative test proves nothing"
8626        );
8627    }
8628
8629    #[tokio::test]
8630    async fn imported_session_cannot_invoke_a_blocklisted_command() {
8631        // audit #31: a crafted session handed to an agent ("replay this to reproduce")
8632        // must not become arbitrary command invocation.
8633        let bridge = RecordingBridge::default();
8634        let state = state_with(blocking(&["wipe_database"]));
8635        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8636
8637        let session = RecordedSession::new(
8638            "poisoned".to_string(),
8639            chrono::Utc::now(),
8640            vec![RecordedEvent::new(
8641                0,
8642                chrono::Utc::now(),
8643                ipc_event("wipe_database"),
8644            )],
8645            Vec::new(),
8646        );
8647        let session_json = serde_json::to_string(&session).unwrap();
8648
8649        let imp = call(
8650            &h,
8651            "recording",
8652            json!({"action": "import", "session_json": session_json}),
8653        )
8654        .await;
8655        assert_ne!(
8656            imp.is_error,
8657            Some(true),
8658            "import itself should succeed: {}",
8659            result_text(&imp)
8660        );
8661
8662        let r = call(&h, "recording", json!({"action": "replay"})).await;
8663        assert!(
8664            !bridge.invoked("wipe_database"),
8665            "SIDE-EFFECT LEAK: an imported session replayed a blocklisted command (audit #31)"
8666        );
8667        assert!(result_text(&r).contains("blocked"));
8668    }
8669
8670    #[tokio::test]
8671    async fn replay_skips_calls_it_cannot_reproduce_faithfully() {
8672        // Recordings do not capture arguments: re-invoking `delete_todo({id:3})` as
8673        // `delete_todo()` is guaranteed wrong, and re-running a failed/pending call reproduces
8674        // nothing. Only successful no-arg calls are replayed.
8675        let state = state_with(PrivacyConfig::default());
8676        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8677        state.recorder.start("s".to_string()).unwrap();
8678        let AppEvent::Ipc(mut with_args) = ipc_event("delete_todo") else {
8679            unreachable!()
8680        };
8681        with_args.arg_size_bytes = 8;
8682        state.recorder.record_event(AppEvent::Ipc(with_args));
8683        let AppEvent::Ipc(mut failed) = ipc_event("flaky") else {
8684            unreachable!()
8685        };
8686        failed.result = IpcResult::Err("boom".to_string());
8687        state.recorder.record_event(AppEvent::Ipc(failed));
8688        state.recorder.record_event(ipc_event("get_counter"));
8689        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8690
8691        let r = call(&h, "recording", json!({"action": "replay"})).await;
8692        let text = result_text(&r);
8693        assert!(
8694            !bridge.invoked("delete_todo"),
8695            "a call with args must not be replayed"
8696        );
8697        assert!(
8698            !bridge.invoked("flaky"),
8699            "a failed call must not be replayed"
8700        );
8701        assert!(
8702            bridge.invoked("get_counter"),
8703            "a successful no-arg call is replayed"
8704        );
8705        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
8706        assert_eq!(v["skipped"], 2, "{text}");
8707        assert_eq!(v["replayed"], 1, "{text}");
8708    }
8709
8710    /// A state whose eval timeout is long enough that only the window/shutdown watch can end
8711    /// an unanswered eval quickly.
8712    fn slow_eval_state() -> Arc<VictauriState> {
8713        let Ok(mut s) = Arc::try_unwrap(state_with(PrivacyConfig::default())) else {
8714            unreachable!("fresh Arc has one owner")
8715        };
8716        s.eval_timeout = std::time::Duration::from_secs(20);
8717        Arc::new(s)
8718    }
8719
8720    #[tokio::test]
8721    async fn eval_reports_a_window_closed_mid_call_instead_of_timing_out() {
8722        // The RecordingBridge answers the liveness probe, never the user code, and lists NO
8723        // windows — i.e. the target window is gone while the call is in flight (what a
8724        // command that closes its own window produces).
8725        let state = slow_eval_state();
8726        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8727        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
8728        let started = std::time::Instant::now();
8729        let r = call(
8730            &h,
8731            "eval_js",
8732            json!({"code": "await new Promise(() => {})", "webview_label": "popup"}),
8733        )
8734        .await;
8735        let text = result_text(&r);
8736        assert_eq!(r.is_error, Some(true), "{text}");
8737        assert!(
8738            text.contains("was closed while the call was in flight"),
8739            "{text}"
8740        );
8741        assert!(
8742            started.elapsed() < std::time::Duration::from_secs(5),
8743            "must not wait out the 20s eval timeout (took {:?})",
8744            started.elapsed()
8745        );
8746    }
8747
8748    /// A bridge whose UI thread never answers a window listing (a busy/wedged UI), but which
8749    /// otherwise behaves like `RecordingBridge`.
8750    struct WedgedListingBridge(RecordingBridge);
8751
8752    impl WebviewBridge for WedgedListingBridge {
8753        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
8754            self.0.eval_webview(label, script)
8755        }
8756        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
8757            self.0.get_window_states(label)
8758        }
8759        fn list_window_labels(&self) -> Vec<String> {
8760            Vec::new()
8761        }
8762        fn try_list_window_labels(&self) -> Result<Vec<String>, String> {
8763            Err("list_window_labels did not complete on the main thread: timed out".to_string())
8764        }
8765        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
8766            self.0.get_native_handle(label)
8767        }
8768        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
8769            self.0.manage_window(label, action)
8770        }
8771        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
8772            self.0.resize_window(label, w, h)
8773        }
8774        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
8775            self.0.move_window(label, x, y)
8776        }
8777        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
8778            self.0.set_window_title(label, title)
8779        }
8780    }
8781
8782    #[tokio::test]
8783    async fn page_originated_evals_cannot_starve_agent_evals() {
8784        // Red-team: page JS can call victauri_eval_js; with one shared pool it could park
8785        // never-resolving evals in all 100 slots and fail every agent eval.
8786        let state = state_with(PrivacyConfig::default());
8787        let mut held = Vec::new();
8788        for i in 0..crate::tools::MAX_PAGE_PENDING_EVALS {
8789            let (tx, rx) = tokio::sync::oneshot::channel();
8790            // Spread over windows so the page-wide budget, not a window's, is what fills up.
8791            let label = format!("w{}", i % 5);
8792            let slot = crate::tools::reserve_page_eval(&state, &label, tx)
8793                .await
8794                .unwrap();
8795            held.push((slot, rx));
8796        }
8797        let (tx, _rx) = tokio::sync::oneshot::channel();
8798        let over = crate::tools::reserve_page_eval(&state, "w9", tx).await;
8799        assert!(over.err().unwrap().contains("page-originated"));
8800        // The agent path still has room.
8801        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8802        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
8803        let (tx, _rx2) = tokio::sync::oneshot::channel();
8804        assert!(h.reserve_pending("agent-1", tx).await.is_ok());
8805        drop(held);
8806    }
8807
8808    #[tokio::test]
8809    async fn one_window_cannot_starve_another_windows_page_evals() {
8810        let state = state_with(PrivacyConfig::default());
8811        let mut held = Vec::new();
8812        let mut refused = None;
8813        // Window "a" (whose label is a prefix of "a:b") fills its own budget and no more.
8814        for _ in 0..=crate::tools::MAX_PAGE_PENDING_EVALS_PER_WINDOW {
8815            let (tx, rx) = tokio::sync::oneshot::channel();
8816            match crate::tools::reserve_page_eval(&state, "a", tx).await {
8817                Ok(slot) => held.push((slot, rx)),
8818                Err(e) => refused = Some(e),
8819            }
8820        }
8821        assert_eq!(held.len(), crate::tools::MAX_PAGE_PENDING_EVALS_PER_WINDOW);
8822        assert!(refused.unwrap().contains("window 'a'"));
8823        let (tx, _rx) = tokio::sync::oneshot::channel();
8824        let other = crate::tools::reserve_page_eval(&state, "a:b", tx).await;
8825        assert!(
8826            other.is_ok(),
8827            "window 'a:b' was starved by window 'a': {:?}",
8828            other.as_ref().err()
8829        );
8830        // Dropping the slots releases them.
8831        drop(held);
8832        drop(other);
8833        assert!(state.pending_evals.lock().await.is_empty());
8834    }
8835
8836    #[tokio::test]
8837    async fn a_busy_ui_is_not_mistaken_for_a_closed_window() {
8838        // Red-team finding: a main-thread listing that times out returned `[]`, which the
8839        // eval watch read as "window closed … it most likely ran — do not re-run it". A
8840        // listing that FAILS is not evidence; the eval must run to its own timeout instead.
8841        let Ok(mut s) = Arc::try_unwrap(state_with(PrivacyConfig::default())) else {
8842            unreachable!("fresh Arc has one owner")
8843        };
8844        s.eval_timeout = std::time::Duration::from_millis(2500);
8845        let state = Arc::new(s);
8846        let bridge = WedgedListingBridge(RecordingBridge::answering(state.pending_evals.clone()));
8847        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
8848        let r = call(
8849            &h,
8850            "eval_js",
8851            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8852        )
8853        .await;
8854        let text = result_text(&r);
8855        assert!(
8856            !text.contains("was closed"),
8857            "false 'window closed': {text}"
8858        );
8859        assert!(text.contains("timed out"), "{text}");
8860    }
8861
8862    /// `RecordingBridge`, but reporting a live `main` window (so only page loads are in play).
8863    struct MainWindowBridge(RecordingBridge);
8864
8865    impl WebviewBridge for MainWindowBridge {
8866        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
8867            self.0.eval_webview(label, script)
8868        }
8869        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
8870            self.0.get_window_states(label)
8871        }
8872        fn list_window_labels(&self) -> Vec<String> {
8873            vec!["main".to_string()]
8874        }
8875        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
8876            self.0.get_native_handle(label)
8877        }
8878        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
8879            self.0.manage_window(label, action)
8880        }
8881        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
8882            self.0.resize_window(label, w, h)
8883        }
8884        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
8885            self.0.move_window(label, x, y)
8886        }
8887        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
8888            self.0.set_window_title(label, title)
8889        }
8890    }
8891
8892    fn eval_state_with_timeout(ms: u64) -> Arc<VictauriState> {
8893        let Ok(mut s) = Arc::try_unwrap(state_with(PrivacyConfig::default())) else {
8894            unreachable!("fresh Arc has one owner")
8895        };
8896        s.eval_timeout = std::time::Duration::from_millis(ms);
8897        Arc::new(s)
8898    }
8899
8900    #[tokio::test]
8901    async fn eval_fails_fast_when_its_page_reloads() {
8902        let state = eval_state_with_timeout(20_000);
8903        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8904        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
8905        let reloader = state.clone();
8906        tokio::spawn(async move {
8907            tokio::time::sleep(std::time::Duration::from_millis(600)).await;
8908            inner.load_page("page-2"); // the page reloaded
8909            ready_signal(&reloader, "main", "page-2");
8910        });
8911        let started = std::time::Instant::now();
8912        let r = call(
8913            &h,
8914            "eval_js",
8915            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8916        )
8917        .await;
8918        let text = result_text(&r);
8919        assert!(text.contains("loaded a new page"), "{text}");
8920        assert!(
8921            started.elapsed() < std::time::Duration::from_secs(3),
8922            "must not wait out the 20s timeout (took {:?})",
8923            started.elapsed()
8924        );
8925        assert!(
8926            state.pending_evals.lock().await.is_empty(),
8927            "pending entry removed"
8928        );
8929    }
8930
8931    #[tokio::test]
8932    async fn a_late_ready_signal_from_the_same_page_is_not_a_reload() {
8933        // The bridge's ready signal is fire-and-forget at init and can land just after our
8934        // probe; it carries the nonce of the page the eval runs in and is not a reload.
8935        let state = eval_state_with_timeout(1_500);
8936        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8937        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner)));
8938        let late = state.clone();
8939        tokio::spawn(async move {
8940            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8941            ready_signal(&late, "main", "page-1");
8942        });
8943        let r = call(
8944            &h,
8945            "eval_js",
8946            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8947        )
8948        .await;
8949        let text = result_text(&r);
8950        assert!(!text.contains("loaded a new page"), "false reload: {text}");
8951        assert!(text.contains("timed out"), "{text}");
8952    }
8953
8954    #[tokio::test]
8955    async fn invoke_command_honors_timeout_ms_and_skips_aborted_timings() {
8956        let state = eval_state_with_timeout(20_000);
8957        let bridge = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone()));
8958        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
8959        let started = std::time::Instant::now();
8960        let r = call(
8961            &h,
8962            "invoke_command",
8963            json!({"command": "slow_thing", "webview_label": "main", "timeout_ms": 400}),
8964        )
8965        .await;
8966        let text = result_text(&r);
8967        assert!(
8968            started.elapsed() < std::time::Duration::from_secs(3),
8969            "timeout_ms ignored"
8970        );
8971        assert!(text.contains("timed out after 400ms"), "{text}");
8972        assert!(
8973            text.contains("timeout_ms"),
8974            "the error must point at timeout_ms: {text}"
8975        );
8976        assert!(
8977            state.command_timings.stats_for("slow_thing").is_none(),
8978            "a timed-out call is not a command duration"
8979        );
8980    }
8981
8982    #[tokio::test]
8983    async fn eval_reports_app_shutdown_instead_of_timing_out() {
8984        let state = slow_eval_state();
8985        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8986        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
8987        let shutdown = state.clone();
8988        tokio::spawn(async move {
8989            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
8990            let _ = shutdown.shutdown_tx.send(true);
8991        });
8992        let r = call(
8993            &h,
8994            "eval_js",
8995            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8996        )
8997        .await;
8998        let text = result_text(&r);
8999        assert_eq!(r.is_error, Some(true), "{text}");
9000        assert!(text.contains("shutting down"), "{text}");
9001        assert!(
9002            state.pending_evals.lock().await.is_empty(),
9003            "pending entry removed"
9004        );
9005    }
9006
9007    /// The bridge's ready signal for window `label`, from a page whose nonce is `nonce`.
9008    fn ready_signal(state: &VictauriState, label: &str, nonce: &str) {
9009        state.page_loads.record_load(label, Some(nonce));
9010    }
9011
9012    /// A UI thread that takes `0.1` to pick up every main-thread round trip (a busy app).
9013    struct SlowUiBridge(MainWindowBridge, std::time::Duration);
9014
9015    impl WebviewBridge for SlowUiBridge {
9016        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
9017            std::thread::sleep(self.1);
9018            self.0.eval_webview(label, script)
9019        }
9020        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9021            self.0.get_window_states(label)
9022        }
9023        fn list_window_labels(&self) -> Vec<String> {
9024            self.0.list_window_labels()
9025        }
9026        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
9027            self.0.get_native_handle(label)
9028        }
9029        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
9030            self.0.manage_window(label, action)
9031        }
9032        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
9033            self.0.resize_window(label, w, h)
9034        }
9035        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
9036            self.0.move_window(label, x, y)
9037        }
9038        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
9039            self.0.set_window_title(label, title)
9040        }
9041    }
9042
9043    /// #83: an eval's timeout covers the WHOLE call. The probe, injection and parse check are
9044    /// main-thread round trips; on a busy UI thread they took seconds each, and the wait for
9045    /// the result then started its own full timeout — a "3 s" eval took ~4.8 s here (three 600 ms
9046    /// round trips + 3 s; a 30 s one up to two minutes in 4DA). Now everything draws on one
9047    /// deadline: ~3 s.
9048    #[tokio::test]
9049    async fn an_eval_timeout_covers_the_whole_call_on_a_busy_ui_thread() {
9050        let state = eval_state_with_timeout(3_000);
9051        let inner = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone()));
9052        let bridge = SlowUiBridge(inner, std::time::Duration::from_millis(600));
9053        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9054        let (text, took) = hanging_eval(&h).await;
9055        assert!(text.contains("timed out"), "{text}");
9056        assert!(
9057            took < std::time::Duration::from_millis(4_000),
9058            "the wait restarted after the slow round trips: {took:?} for a 3 s eval"
9059        );
9060        assert!(
9061            state.pending_evals.lock().await.is_empty(),
9062            "pending entry removed"
9063        );
9064    }
9065
9066    /// A SHORT timeout on a slow (loaded) machine must not turn into a false failure: the probe
9067    /// and injection get the minimum send window, so a healthy page that answers the probe in
9068    /// 300 ms still receives a 100 ms call's code — instead of a "bridge not responding" that
9069    /// would send the agent hunting for a missing capability.
9070    #[tokio::test]
9071    async fn a_short_timeout_still_sends_when_the_probe_is_slower_than_it() {
9072        let state = eval_state_with_timeout(100);
9073        let recording = RecordingBridge::answering(state.pending_evals.clone());
9074        let bridge = SlowUiBridge(
9075            MainWindowBridge(recording.clone()),
9076            std::time::Duration::from_millis(300),
9077        );
9078        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9079        let r = call(
9080            &h,
9081            "invoke_command",
9082            json!({"command": "greet", "webview_label": "main"}),
9083        )
9084        .await;
9085        let text = result_text(&r);
9086        assert!(
9087            recording.invoked("greet"),
9088            "the code was never sent: {text}"
9089        );
9090        assert!(
9091            !text.contains("not responding"),
9092            "false dead-page diagnosis: {text}"
9093        );
9094    }
9095
9096    /// Pre-release audit: the result floor was set BEFORE the parse check, so on a busy UI
9097    /// thread the check could eat it — code that reached the page got less than its promised
9098    /// min(timeout, 1 s), and an answer arriving just after the check was reported as "timed out"
9099    /// (an agent retrying a side-effecting command ran it twice). Here every round trip takes
9100    /// 700 ms and the code answers 900 ms after delivery: injected at ~1.4 s, check done at
9101    /// ~2.1 s, answer at ~2.3 s — inside the floor counted from the check (2.6 s), past the one
9102    /// counted from injection (1.9 s).
9103    #[tokio::test]
9104    async fn code_that_reached_the_page_gets_its_full_result_window() {
9105        let state = eval_state_with_timeout(500);
9106        let recording = RecordingBridge::answering(state.pending_evals.clone());
9107        recording.answer_evals_after(
9108            r#"{"__victauri_ok":2,"__victauri_type":"value"}"#,
9109            std::time::Duration::from_millis(900),
9110        );
9111        let bridge = SlowUiBridge(
9112            MainWindowBridge(recording),
9113            std::time::Duration::from_millis(700),
9114        );
9115        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9116        let outcome = h
9117            .eval_outcome("1 + 1", Some("main"), std::time::Duration::from_millis(500))
9118            .await;
9119        assert!(
9120            outcome.is_ok(),
9121            "the answer came inside the result window but was reported as {:?}",
9122            outcome.err().map(|f| f.message)
9123        );
9124    }
9125
9126    /// Pre-release audit: a page whose bridge never signals ready (not loaded, or a window
9127    /// without the `victauri:default` capability) must still be PROBED within a short timeout,
9128    /// so the agent gets "bridge not responding — missing capability / page not loaded" — the
9129    /// ready wait used to eat the whole send window, and the call blamed a busy UI thread.
9130    #[tokio::test]
9131    async fn a_page_that_never_signals_ready_gets_the_dead_page_diagnosis() {
9132        let state = eval_state_with_timeout(1_000);
9133        state
9134            .bridge_ready
9135            .store(false, std::sync::atomic::Ordering::SeqCst);
9136        // No pending map: the liveness probe is never answered (no bridge in the page).
9137        let h = VictauriMcpHandler::new(
9138            state,
9139            Arc::new(MainWindowBridge(RecordingBridge::default())),
9140        );
9141        let started = std::time::Instant::now();
9142        let failure = h
9143            .eval_outcome("1 + 1", Some("main"), std::time::Duration::from_secs(1))
9144            .await
9145            .expect_err("nothing answers");
9146        assert_eq!(
9147            failure.kind,
9148            EvalFailureKind::NotSent,
9149            "{}",
9150            failure.message
9151        );
9152        assert!(
9153            failure.message.contains("bridge not responding"),
9154            "wrong diagnosis: {}",
9155            failure.message
9156        );
9157        assert!(
9158            started.elapsed() < std::time::Duration::from_millis(4_000),
9159            "{:?}",
9160            started.elapsed()
9161        );
9162    }
9163
9164    /// The call budget actually reaches the main-thread round trips: the probe and the
9165    /// injection run under it (capped near the call's send window), not the 10 s default.
9166    /// (`SlowUiBridge` alone cannot show this — it sleeps whether or not a budget exists.)
9167    #[tokio::test]
9168    async fn every_eval_round_trip_runs_under_the_calls_budget() {
9169        struct BudgetSpy(MainWindowBridge, Arc<StdMutex<Vec<std::time::Duration>>>);
9170        impl WebviewBridge for BudgetSpy {
9171            fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
9172                self.1
9173                    .lock()
9174                    .unwrap_or_else(std::sync::PoisonError::into_inner)
9175                    .push(crate::bridge::start_timeout());
9176                self.0.eval_webview(label, script)
9177            }
9178            fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9179                self.0.get_window_states(label)
9180            }
9181            fn list_window_labels(&self) -> Vec<String> {
9182                self.0.list_window_labels()
9183            }
9184            fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
9185                self.0.get_native_handle(label)
9186            }
9187            fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
9188                self.0.manage_window(label, action)
9189            }
9190            fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
9191                self.0.resize_window(label, w, h)
9192            }
9193            fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
9194                self.0.move_window(label, x, y)
9195            }
9196            fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
9197                self.0.set_window_title(label, title)
9198            }
9199        }
9200        let state = eval_state_with_timeout(300);
9201        let recording = RecordingBridge::answering(state.pending_evals.clone());
9202        recording.answer_evals_with(r#"{"__victauri_ok":2,"__victauri_type":"value"}"#);
9203        let seen = Arc::new(StdMutex::new(Vec::new()));
9204        let h = VictauriMcpHandler::new(
9205            state,
9206            Arc::new(BudgetSpy(MainWindowBridge(recording), Arc::clone(&seen))),
9207        );
9208        h.eval_outcome("1 + 1", Some("main"), std::time::Duration::from_millis(300))
9209            .await
9210            .expect("healthy page answers");
9211        let seen = seen
9212            .lock()
9213            .unwrap_or_else(std::sync::PoisonError::into_inner)
9214            .clone();
9215        assert!(
9216            seen.len() >= 3,
9217            "probe, injection and parse check: {seen:?}"
9218        );
9219        assert!(
9220            seen.iter().all(|b| *b <= MIN_SEND_WINDOW && !b.is_zero()),
9221            "a round trip ran outside the call's budget: {seen:?}"
9222        );
9223    }
9224
9225    /// A call that runs out of time before its code could be sent says so — and that the code
9226    /// never ran — instead of waiting for the UI thread.
9227    #[tokio::test]
9228    async fn an_eval_out_of_time_before_sending_reports_not_sent() {
9229        let state = eval_state_with_timeout(300);
9230        let inner = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone()));
9231        // Slower than the 2.5 s minimum send window, so the probe itself exhausts it.
9232        let bridge = SlowUiBridge(inner, std::time::Duration::from_millis(2_700));
9233        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9234        let failure = h
9235            .eval_outcome("1 + 1", Some("main"), std::time::Duration::from_millis(300))
9236            .await
9237            .expect_err("the budget ran out during the probe");
9238        assert_eq!(
9239            failure.kind,
9240            EvalFailureKind::NotSent,
9241            "{}",
9242            failure.message
9243        );
9244    }
9245
9246    /// Lens B (0.10.1 audit): the page commands (`victauri_eval_js` / `victauri_dom_snapshot`)
9247    /// run on the APP's runtime and take `pending_evals` with a fair-queued `.lock().await`. If
9248    /// such a waiter is queued when a Victauri map op releases the lock, tokio hands the lock to
9249    /// THAT task — which cannot run while the app pins its runtime. Every agent eval then hangs
9250    /// in `reserve_pending` (unbounded, before any deadline) until the app lets go.
9251    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
9252    async fn a_page_eval_queued_on_a_pinned_app_runtime_cannot_hang_agent_evals() {
9253        let state = eval_state_with_timeout(1_000);
9254        let recording = RecordingBridge::answering(state.pending_evals.clone());
9255        recording.answer_evals_with(r#"{"__victauri_ok":2,"__victauri_type":"value"}"#);
9256        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(recording)));
9257        // Tauri's runtime, reduced to one worker.
9258        let app_rt = tokio::runtime::Builder::new_multi_thread()
9259            .worker_threads(1)
9260            .enable_all()
9261            .build()
9262            .unwrap();
9263        // A Victauri map operation is in flight when the page asks for the lock.
9264        let guard = state.pending_evals.lock().await;
9265        let page_state = state.clone();
9266        app_rt.spawn(async move {
9267            let (tx, _rx) = tokio::sync::oneshot::channel();
9268            let _slot = crate::tools::reserve_page_eval(&page_state, "main", tx).await;
9269        });
9270        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9271        // The app blocks its runtime (a blocking call inside async code) for 6 s.
9272        app_rt.spawn(async { std::thread::sleep(std::time::Duration::from_secs(6)) });
9273        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9274        drop(guard);
9275
9276        let started = std::time::Instant::now();
9277        let out = tokio::time::timeout(
9278            std::time::Duration::from_secs(12),
9279            h.eval_outcome("1 + 1", Some("main"), std::time::Duration::from_secs(1)),
9280        )
9281        .await;
9282        let took = started.elapsed();
9283        app_rt.shutdown_background();
9284        // Documented bound: max(timeout, 2.5 s) + 2 s = 4.5 s.
9285        assert!(
9286            took < std::time::Duration::from_millis(4_500),
9287            "a 1 s agent eval hung {took:?} behind a page eval queued on the app's pinned \
9288             runtime (outcome: {:?})",
9289            out.map(|r| r.map_err(|f| f.message))
9290        );
9291    }
9292
9293    /// LENS-A-1: `wait_for`'s 12 s send slack assumed ONE main-thread start wait (10 s) plus the
9294    /// probe's 2 s answer — but the probe AND the injection each wait up to 10 s to start. On a
9295    /// UI thread that takes 6.5 s to pick up each round trip (inside every budget, so a real
9296    /// `on_main` would run them, not abandon them), the code reaches the page at ~13 s; the
9297    /// page's `waitFor` answers after its own 10 s (at ~23 s), past the eval's 22 s deadline —
9298    /// so a page answer (here: the condition was met at 9.9 s of page time) is reported as
9299    /// "eval timed out … an unresolved promise".
9300    #[tokio::test]
9301    async fn a_page_side_wait_for_answer_is_not_cut_off_by_slow_sending() {
9302        let state = eval_state_with_timeout(30_000);
9303        let recording = RecordingBridge::answering(state.pending_evals.clone());
9304        recording.answer_evals_after(
9305            r#"{"__victauri_ok":{"ok":true,"elapsed_ms":9900},"__victauri_type":"value"}"#,
9306            std::time::Duration::from_millis(9_900),
9307        );
9308        let bridge = SlowUiBridge(
9309            MainWindowBridge(recording),
9310            std::time::Duration::from_millis(6_500),
9311        );
9312        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9313        let r = call(
9314            &h,
9315            "wait_for",
9316            json!({"condition": "selector", "value": "#x", "timeout_ms": 10_000,
9317                   "webview_label": "main"}),
9318        )
9319        .await;
9320        let text = result_text(&r);
9321        assert!(
9322            !text.contains("timed out"),
9323            "the page's own wait_for answer was cut off by the send slack: {text}"
9324        );
9325        assert!(text.contains("\"ok\":true"), "{text}");
9326    }
9327
9328    /// LENS-A-2: a budget-honoring busy UI (a closure not picked up within the round trip's
9329    /// start budget is abandoned, as `on_main` does): 1.3 s pickup latency. The probe takes
9330    /// 1.3 s of the 2.5 s send window, the injection gets 1.2 s and is abandoned. The call is
9331    /// truthfully `NotSent`, but its message is the bare bridge error — not the "ran out of time
9332    /// before the code was sent … UI thread is busy … pass a longer timeout" diagnosis the same
9333    /// exhaustion gets one step earlier.
9334    #[tokio::test]
9335    async fn an_injection_abandoned_for_lack_of_budget_gets_the_busy_ui_diagnosis() {
9336        struct BudgetedSlowUi(MainWindowBridge, std::time::Duration);
9337        impl WebviewBridge for BudgetedSlowUi {
9338            fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
9339                let budget = crate::bridge::start_timeout();
9340                if self.1 > budget {
9341                    std::thread::sleep(budget);
9342                    return Err(
9343                        "eval_webview did not complete on the main thread: timed out \
9344                                before it started (it will not run)"
9345                            .to_string(),
9346                    );
9347                }
9348                std::thread::sleep(self.1);
9349                self.0.eval_webview(label, script)
9350            }
9351            fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9352                self.0.get_window_states(label)
9353            }
9354            fn list_window_labels(&self) -> Vec<String> {
9355                self.0.list_window_labels()
9356            }
9357            fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
9358                self.0.get_native_handle(label)
9359            }
9360            fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
9361                self.0.manage_window(label, action)
9362            }
9363            fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
9364                self.0.resize_window(label, w, h)
9365            }
9366            fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
9367                self.0.move_window(label, x, y)
9368            }
9369            fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
9370                self.0.set_window_title(label, title)
9371            }
9372        }
9373        // 1.3 s: the injection is the step that runs out; 2.7 s: the probe itself does.
9374        for latency_ms in [1_300, 2_700] {
9375            let state = eval_state_with_timeout(1_000);
9376            let recording = RecordingBridge::answering(state.pending_evals.clone());
9377            let bridge = BudgetedSlowUi(
9378                MainWindowBridge(recording),
9379                std::time::Duration::from_millis(latency_ms),
9380            );
9381            let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9382            let failure = h
9383                .eval_outcome(
9384                    "1 + 1",
9385                    Some("main"),
9386                    std::time::Duration::from_millis(1_000),
9387                )
9388                .await
9389                .expect_err("the delivery could not start in time");
9390            assert_eq!(
9391                failure.kind,
9392                EvalFailureKind::NotSent,
9393                "{}",
9394                failure.message
9395            );
9396            assert!(
9397                failure.message.contains("UI thread is busy")
9398                    && failure.message.contains("longer timeout"),
9399                "{latency_ms} ms: no busy-UI diagnosis / remedy: {}",
9400                failure.message
9401            );
9402        }
9403    }
9404
9405    async fn hanging_eval(h: &VictauriMcpHandler) -> (String, std::time::Duration) {
9406        let started = std::time::Instant::now();
9407        let r = call(
9408            h,
9409            "eval_js",
9410            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
9411        )
9412        .await;
9413        (result_text(&r), started.elapsed())
9414    }
9415
9416    #[tokio::test]
9417    async fn a_reload_right_after_injection_is_detected() {
9418        // A reload that completes within a few ms of the injection (eval_js("location.reload()"),
9419        // navigate, a fast HMR) was hidden by a 250ms grace and waited out the full timeout.
9420        let state = eval_state_with_timeout(20_000);
9421        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
9422        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
9423        let reloader = state.clone();
9424        tokio::spawn(async move {
9425            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
9426            inner.load_page("page-2");
9427            ready_signal(&reloader, "main", "page-2");
9428        });
9429        let (text, took) = hanging_eval(&h).await;
9430        assert!(text.contains("loaded a new page"), "{text}");
9431        assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
9432    }
9433
9434    #[tokio::test]
9435    async fn a_same_page_ready_signal_arriving_late_is_not_a_reload() {
9436        // Under load the page's own ready signal can land seconds after the probe; it carries the
9437        // nonce the eval was armed in, so the eval keeps waiting for its real result.
9438        let state = eval_state_with_timeout(3_000);
9439        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
9440        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner)));
9441        let late = state.clone();
9442        tokio::spawn(async move {
9443            tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
9444            ready_signal(&late, "main", "page-1");
9445        });
9446        let (text, _) = hanging_eval(&h).await;
9447        assert!(!text.contains("loaded a new page"), "false reload: {text}");
9448        assert!(text.contains("timed out"), "{text}");
9449    }
9450
9451    #[tokio::test]
9452    async fn a_forged_ready_signal_does_not_abort_an_eval() {
9453        // Page script can call victauri_eval_callback('__victauri_bridge_ready__') itself. The
9454        // page did not change (the probe still reports the armed nonce), so it is not a reload.
9455        let state = eval_state_with_timeout(2_500);
9456        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
9457        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner)));
9458        let forger = state.clone();
9459        tokio::spawn(async move {
9460            tokio::time::sleep(std::time::Duration::from_millis(600)).await;
9461            ready_signal(&forger, "main", "forged");
9462        });
9463        let (text, _) = hanging_eval(&h).await;
9464        assert!(!text.contains("loaded a new page"), "forged reload: {text}");
9465        assert!(text.contains("timed out"), "{text}");
9466    }
9467
9468    /// R4-EVAL1: a ready signal whose confirming probe FAILS (a busy UI thread, a full slot
9469    /// map) is no evidence of a new page. The eval used to abort as "loaded a new page" — which
9470    /// page script can provoke with a forged signal while the UI is busy, and which invites a
9471    /// retry that runs side-effecting code twice. It must keep waiting (its own timeout bounds
9472    /// it).
9473    #[tokio::test]
9474    async fn an_unconfirmed_ready_signal_does_not_abort_an_eval() {
9475        let state = eval_state_with_timeout(4_000);
9476        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
9477        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
9478        let signal = state.clone();
9479        tokio::spawn(async move {
9480            tokio::time::sleep(std::time::Duration::from_millis(300)).await;
9481            inner.probe_silent.store(true, Ordering::SeqCst); // the UI stops answering
9482            ready_signal(&signal, "main", "forged");
9483        });
9484        let (text, _) = hanging_eval(&h).await;
9485        assert!(
9486            !text.contains("loaded a new page"),
9487            "aborted on no evidence: {text}"
9488        );
9489        assert!(text.contains("timed out"), "{text}");
9490    }
9491
9492    /// B-L5: the timeout message rules out a parse error only when the fast parse check was
9493    /// actually armed (the page reported a nonce) and delivered; otherwise it cannot know.
9494    #[tokio::test]
9495    async fn a_timeout_claims_no_parse_error_only_when_the_parse_check_ran() {
9496        let state = eval_state_with_timeout(300);
9497        // No nonce from the probe: the parse check is disabled.
9498        let h = VictauriMcpHandler::new(
9499            state.clone(),
9500            Arc::new(RecordingBridge::answering(state.pending_evals.clone())),
9501        );
9502        let (text, _) = hanging_eval(&h).await;
9503        assert!(text.contains("timed out"), "{text}");
9504        assert!(!text.contains("NOT a parse"), "unfounded claim: {text}");
9505        assert!(text.contains("could not be ruled out"), "{text}");
9506        // Armed and delivered: the claim holds.
9507        let h = VictauriMcpHandler::new(
9508            state.clone(),
9509            Arc::new(RecordingBridge::in_page(state.pending_evals.clone(), "p1")),
9510        );
9511        let (text, _) = hanging_eval(&h).await;
9512        assert!(text.contains("NOT a parse error"), "{text}");
9513        // Armed but the check could not be delivered.
9514        let h = VictauriMcpHandler::new(
9515            state.clone(),
9516            Arc::new(CheckNotDeliveredBridge(RecordingBridge::in_page(
9517                state.pending_evals.clone(),
9518                "p1",
9519            ))),
9520        );
9521        let (text, _) = hanging_eval(&h).await;
9522        assert!(!text.contains("NOT a parse"), "unfounded claim: {text}");
9523    }
9524
9525    /// Delivers everything but the eval's parse-check script.
9526    struct CheckNotDeliveredBridge(RecordingBridge);
9527
9528    impl WebviewBridge for CheckNotDeliveredBridge {
9529        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
9530            if script.contains("_evalCheck") {
9531                return Err("window busy".to_string());
9532            }
9533            self.0.eval_webview(label, script)
9534        }
9535        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9536            self.0.get_window_states(label)
9537        }
9538        fn list_window_labels(&self) -> Vec<String> {
9539            vec!["main".to_string()]
9540        }
9541        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
9542            self.0.get_native_handle(label)
9543        }
9544        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
9545            self.0.manage_window(label, action)
9546        }
9547        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
9548            self.0.resize_window(label, w, h)
9549        }
9550        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
9551            self.0.move_window(label, x, y)
9552        }
9553        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
9554            self.0.set_window_title(label, title)
9555        }
9556    }
9557
9558    /// A page that answers WITHOUT the armed nonce (the bridge that armed the eval is gone)
9559    /// is positive evidence of a new page.
9560    #[tokio::test]
9561    async fn a_page_without_the_armed_nonce_is_a_reload() {
9562        let state = eval_state_with_timeout(20_000);
9563        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
9564        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
9565        let reloader = state.clone();
9566        tokio::spawn(async move {
9567            tokio::time::sleep(std::time::Duration::from_millis(300)).await;
9568            *inner
9569                .page_nonce
9570                .lock()
9571                .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
9572            ready_signal(&reloader, "main", "whatever");
9573        });
9574        let (text, took) = hanging_eval(&h).await;
9575        assert!(text.contains("loaded a new page"), "{text}");
9576        assert!(took < std::time::Duration::from_secs(5), "took {took:?}");
9577    }
9578
9579    #[tokio::test]
9580    async fn an_eval_started_after_app_exit_fails_fast() {
9581        // `subscribe()` marks the current value seen: an eval started after the exit signal
9582        // waited its whole timeout for a change that had already happened.
9583        let state = slow_eval_state();
9584        state.shutdown_tx.send_replace(true);
9585        let bridge = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone()));
9586        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9587        let (text, took) =
9588            tokio::time::timeout(std::time::Duration::from_secs(10), hanging_eval(&h))
9589                .await
9590                .expect("an eval after app exit must not wait out its 20s timeout");
9591        assert!(text.contains("shutting down"), "{text}");
9592        assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
9593    }
9594
9595    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
9596    async fn a_dropped_eval_call_releases_its_pending_slot() {
9597        // REST runs the tool inside the axum future: a client that disconnects or times out
9598        // drops it mid-wait. The pending entry used to leak; 100 of them wedged every eval.
9599        let state = eval_state_with_timeout(20_000);
9600        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9601        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9602        for _ in 0..3 {
9603            let dropped = tokio::time::timeout(
9604                std::time::Duration::from_millis(300),
9605                h.execute_tool("eval_js", json!({"code": "await new Promise(() => {})"})),
9606            )
9607            .await;
9608            assert!(
9609                dropped.is_err(),
9610                "the call must still be in flight when dropped"
9611            );
9612        }
9613        // The recording drain reserves slots too (its page never answers here). It only reads
9614        // while a recording is active and its drain epoch is set, as `recording start` does.
9615        let generation = state
9616            .recorder
9617            .start_session("slot-release".to_string())
9618            .unwrap();
9619        state.drain_watermarks.reset(0.0, generation);
9620        let bridge: Arc<dyn WebviewBridge> = Arc::new(RecordingBridge::default());
9621        let dropped = tokio::time::timeout(
9622            std::time::Duration::from_millis(300),
9623            drain_window_into_recording(&state, &bridge, "main"),
9624        )
9625        .await;
9626        assert!(
9627            dropped.is_err(),
9628            "the drain must still be in flight when dropped"
9629        );
9630        // Removal on drop may be deferred to a task when the map is momentarily locked.
9631        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9632        assert!(
9633            state.pending_evals.lock().await.is_empty(),
9634            "a dropped call leaked its pending-eval slot"
9635        );
9636    }
9637
9638    /// Answers the liveness probe, but every other script fails to inject.
9639    struct InjectFailsBridge(RecordingBridge);
9640
9641    impl WebviewBridge for InjectFailsBridge {
9642        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
9643            self.0.eval_webview(label, script)?;
9644            if script.contains("probe_ok") {
9645                Ok(())
9646            } else {
9647                Err("window not found: main".to_string())
9648            }
9649        }
9650        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9651            self.0.get_window_states(label)
9652        }
9653        fn list_window_labels(&self) -> Vec<String> {
9654            vec!["main".to_string()]
9655        }
9656        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
9657            self.0.get_native_handle(label)
9658        }
9659        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
9660            self.0.manage_window(label, action)
9661        }
9662        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
9663            self.0.resize_window(label, w, h)
9664        }
9665        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
9666            self.0.move_window(label, x, y)
9667        }
9668        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
9669            self.0.set_window_title(label, title)
9670        }
9671    }
9672
9673    #[tokio::test]
9674    async fn invoke_command_records_no_timing_for_calls_that_never_ran() {
9675        // Only a call that reached the command is a command duration: a saturated pending map
9676        // (~0ms), a dead bridge (~2s probe) or a failed injection measure nothing about it.
9677        let state = eval_state_with_timeout(1_000);
9678        let fillers: Vec<_> = (0..MAX_PENDING_EVALS)
9679            .map(|_| tokio::sync::oneshot::channel::<String>())
9680            .collect();
9681        {
9682            let mut p = state.pending_evals.lock().await;
9683            for (i, (tx, _)) in fillers.into_iter().enumerate() {
9684                p.insert(format!("filler-{i}"), tx);
9685            }
9686        }
9687        let answering = RecordingBridge::answering(state.pending_evals.clone());
9688        let h = VictauriMcpHandler::new(state.clone(), Arc::new(answering.clone()));
9689        let r = call(&h, "invoke_command", json!({"command": "saturated"})).await;
9690        assert!(
9691            result_text(&r).contains("too many concurrent"),
9692            "{}",
9693            result_text(&r)
9694        );
9695        state.pending_evals.lock().await.clear();
9696
9697        let dead = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
9698        let r = call(&dead, "invoke_command", json!({"command": "dead_bridge"})).await;
9699        assert!(
9700            result_text(&r).contains("bridge not responding"),
9701            "{}",
9702            result_text(&r)
9703        );
9704
9705        let failing = VictauriMcpHandler::new(
9706            state.clone(),
9707            Arc::new(InjectFailsBridge(RecordingBridge::answering(
9708                state.pending_evals.clone(),
9709            ))),
9710        );
9711        let r = call(
9712            &failing,
9713            "invoke_command",
9714            json!({"command": "not_injected"}),
9715        )
9716        .await;
9717        assert!(
9718            result_text(&r).contains("injection failed"),
9719            "{}",
9720            result_text(&r)
9721        );
9722
9723        for cmd in ["saturated", "dead_bridge", "not_injected"] {
9724            assert!(
9725                state.command_timings.stats_for(cmd).is_none(),
9726                "'{cmd}' never ran but was recorded as a command timing"
9727            );
9728        }
9729
9730        // Code that did not parse never ran either.
9731        answering.answer_evals_with(r#"{"__victauri_not_run":"did not begin executing"}"#);
9732        let r = call(&h, "invoke_command", json!({"command": "never_parsed"})).await;
9733        assert!(
9734            result_text(&r).contains("parse error"),
9735            "{}",
9736            result_text(&r)
9737        );
9738        assert!(state.command_timings.stats_for("never_parsed").is_none());
9739
9740        // Positive control: a call that ran (and threw) IS a timing.
9741        answering.answer_evals_with(r#"{"__victauri_err":"boom"}"#);
9742        let r = call(&h, "invoke_command", json!({"command": "ran_and_threw"})).await;
9743        assert!(result_text(&r).contains("boom"), "{}", result_text(&r));
9744        assert!(state.command_timings.stats_for("ran_and_threw").is_some());
9745    }
9746
9747    #[tokio::test]
9748    async fn a_trusted_key_press_stops_when_its_element_cannot_be_focused() {
9749        // The focus result used to be ignored and the OS key sent regardless — into whatever
9750        // element (or app) held focus.
9751        let state = eval_state_with_timeout(2_000);
9752        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9753        bridge.answer_evals_with(r#"{"__victauri_ok":false,"__victauri_type":"value"}"#);
9754        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9755        let r = call(
9756            &h,
9757            "input",
9758            json!({"action": "press_key", "key": "Enter", "ref_id": "e9", "trusted": true}),
9759        )
9760        .await;
9761        let text = result_text(&r);
9762        assert_eq!(r.is_error, Some(true), "{text}");
9763        assert!(text.contains("not focusable"), "key sent anyway: {text}");
9764    }
9765
9766    /// G-12: `route add` forwarded any `delay_ms` (a u64) to the page, where a delayed request
9767    /// is held that long. It is capped like a `fault` delay, refused before reaching the page.
9768    #[tokio::test]
9769    async fn route_delay_is_capped() {
9770        let state = eval_state_with_timeout(2_000);
9771        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9772        bridge.answer_evals_with(&ok_envelope(&json!({"ok": true, "id": 1})));
9773        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9774        let added = |b: &RecordingBridge| {
9775            b.scripts
9776                .lock()
9777                .unwrap_or_else(std::sync::PoisonError::into_inner)
9778                .iter()
9779                .filter(|s| s.contains("addRoute("))
9780                .count()
9781        };
9782        let r = call(
9783            &h,
9784            "route",
9785            json!({"action": "add", "pattern": "/api", "behavior": "delay",
9786                   "delay_ms": MAX_FAULT_DELAY_MS + 1}),
9787        )
9788        .await;
9789        let text = result_text(&r);
9790        assert_eq!(r.is_error, Some(true), "{text}");
9791        assert!(text.contains("delay_ms"), "{text}");
9792        assert_eq!(added(&bridge), 0, "the rule must not reach the page");
9793        // At the cap it is accepted.
9794        let r = call(
9795            &h,
9796            "route",
9797            json!({"action": "add", "pattern": "/api", "behavior": "delay",
9798                   "delay_ms": MAX_FAULT_DELAY_MS}),
9799        )
9800        .await;
9801        assert_ne!(r.is_error, Some(true), "{}", result_text(&r));
9802        assert_eq!(added(&bridge), 1);
9803    }
9804
9805    /// R5-ANIM1: `animation scrub capture=true` reported `"captured": true` with no filmstrip
9806    /// when no frame could be captured (here: no native window handle), hiding the failure.
9807    #[tokio::test]
9808    async fn animation_scrub_never_claims_a_capture_it_did_not_make() {
9809        let state = eval_state_with_timeout(2_000);
9810        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9811        bridge.answer_evals_with(&ok_envelope(
9812            &json!({"prepared": true, "duration": 100, "anim_count": 1, "t": 0}),
9813        ));
9814        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9815        let r = call(
9816            &h,
9817            "animation",
9818            json!({"action": "scrub", "selector": "#toast", "points": 2, "capture": true}),
9819        )
9820        .await;
9821        let text = result_text(&r);
9822        assert_ne!(
9823            r.is_error,
9824            Some(true),
9825            "the geometry curve is still returned: {text}"
9826        );
9827        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
9828        assert_eq!(v["captured"], false, "{v}");
9829        assert!(v.get("filmstrip").is_none(), "{v}");
9830        assert!(
9831            v["capture_error"]
9832                .as_str()
9833                .is_some_and(|e| e.contains("no handle")),
9834            "the capture failure must be surfaced: {v}"
9835        );
9836        assert_eq!(v["curve"].as_array().map(Vec::len), Some(2), "{v}");
9837    }
9838
9839    /// The eval envelope for a page result `value`.
9840    /// R5-JS5: `limit: 0` means "return at most zero entries". The log JS used `.slice(-0)`
9841    /// (and the bridge treats a falsy limit as "all"), so it returned EVERY entry.
9842    #[tokio::test]
9843    async fn log_limit_zero_returns_no_entries() {
9844        let state = eval_state_with_timeout(2_000);
9845        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9846        bridge.answer_evals_with(&ok_envelope(&json!([{"a": 1}, {"a": 2}])));
9847        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9848        for action in [
9849            "console",
9850            "network",
9851            "ipc",
9852            "navigation",
9853            "dialogs",
9854            "events",
9855        ] {
9856            let r = call(&h, "logs", json!({"action": action, "limit": 0})).await;
9857            let text = result_text(&r);
9858            assert_ne!(r.is_error, Some(true), "{action}: {text}");
9859            let v: serde_json::Value = serde_json::from_str(&text)
9860                .unwrap_or_else(|e| panic!("{action}: not JSON ({e}): {text}"));
9861            assert_eq!(
9862                v,
9863                json!([]),
9864                "logs {action} limit=0 returned entries: {text}"
9865            );
9866        }
9867        let r = call(&h, "route", json!({"action": "matches", "limit": 0})).await;
9868        let text = result_text(&r);
9869        let v: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
9870        assert_eq!(
9871            v,
9872            json!([]),
9873            "route matches limit=0 returned entries: {text}"
9874        );
9875    }
9876
9877    fn ok_envelope(value: &serde_json::Value) -> String {
9878        json!({"__victauri_ok": value, "__victauri_type": "object"}).to_string()
9879    }
9880
9881    /// R4-IN1: trusted typing / key presses go out only when the page confirms focus landed
9882    /// on the element; an element that exists but did not take focus stops the input.
9883    #[tokio::test]
9884    async fn trusted_keys_are_sent_only_when_focus_landed_on_the_element() {
9885        let state = eval_state_with_timeout(2_000);
9886        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9887        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9888        let type_args =
9889            json!({"action": "type_text", "ref_id": "e3", "text": "hi", "trusted": true});
9890        let key_args =
9891            json!({"action": "press_key", "key": "Enter", "ref_id": "e3", "trusted": true});
9892
9893        bridge.answer_evals_with(&ok_envelope(&json!({"found": true, "focused": false})));
9894        for args in [&type_args, &key_args] {
9895            let r = call(&h, "input", args.clone()).await;
9896            let text = result_text(&r);
9897            assert_eq!(r.is_error, Some(true), "{args}: {text}");
9898            assert!(text.contains("focus did not land"), "{args}: {text}");
9899        }
9900        bridge.answer_evals_with(&ok_envelope(&json!({"found": false, "focused": false})));
9901        let r = call(&h, "input", type_args.clone()).await;
9902        assert!(
9903            result_text(&r).contains("ref not found"),
9904            "{}",
9905            result_text(&r)
9906        );
9907        assert!(
9908            bridge.natives().is_empty(),
9909            "keys sent: {:?}",
9910            bridge.natives()
9911        );
9912
9913        // Positive control: confirmed focus → the OS input goes out.
9914        bridge.answer_evals_with(&ok_envelope(&json!({"found": true, "focused": true})));
9915        for args in [&type_args, &key_args] {
9916            let r = call(&h, "input", args.clone()).await;
9917            assert_ne!(r.is_error, Some(true), "{args}: {}", result_text(&r));
9918        }
9919        assert_eq!(bridge.natives(), vec!["type hi", "key Enter"]);
9920    }
9921
9922    /// R4-IN2: a trusted click is sent only at a point the page vouched for, and never at an
9923    /// unusable one.
9924    #[tokio::test]
9925    async fn trusted_click_is_refused_unless_the_page_reports_a_clickable_point() {
9926        let state = eval_state_with_timeout(2_000);
9927        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9928        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9929        let args = json!({"action": "click", "ref_id": "e5", "trusted": true});
9930        for (answer, expect) in [
9931            (
9932                json!({"error": "element is covered at its center point by <div>"}),
9933                "covered",
9934            ),
9935            (json!({"x": -4.0, "y": 10.0}), "unusable click point"),
9936            (json!(null), "ref not found"),
9937        ] {
9938            bridge.answer_evals_with(&ok_envelope(&answer));
9939            let r = call(&h, "interact", args.clone()).await;
9940            let text = result_text(&r);
9941            assert_eq!(r.is_error, Some(true), "{answer}: {text}");
9942            assert!(text.contains(expect), "{answer}: {text}");
9943        }
9944        assert!(
9945            bridge.natives().is_empty(),
9946            "clicked: {:?}",
9947            bridge.natives()
9948        );
9949        bridge.answer_evals_with(&ok_envelope(&json!({"x": 150.0, "y": 226.0})));
9950        let r = call(&h, "interact", args).await;
9951        assert_ne!(r.is_error, Some(true), "{}", result_text(&r));
9952        assert_eq!(bridge.natives(), vec!["click 150,226"]);
9953    }
9954
9955    #[tokio::test]
9956    async fn an_unserializable_result_is_reported_as_code_that_ran() {
9957        // A circular object / BigInt result used to read "JavaScript error: …" although the code
9958        // ran — an agent then re-ran side-effecting code to "fix" it.
9959        let state = eval_state_with_timeout(2_000);
9960        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9961        bridge.answer_evals_with(
9962            r#"{"__victauri_unserializable":"Do not know how to serialize a BigInt"}"#,
9963        );
9964        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9965        let r = call(&h, "eval_js", json!({"code": "return 1n"})).await;
9966        let text = result_text(&r);
9967        assert_eq!(r.is_error, Some(true), "{text}");
9968        assert!(text.contains("the code ran"), "{text}");
9969        assert!(text.contains("BigInt"), "{text}");
9970        assert!(!text.contains("JavaScript error"), "{text}");
9971    }
9972
9973    #[tokio::test]
9974    async fn trace_stop_never_stops_a_recording_it_did_not_start() {
9975        let state = state_with(PrivacyConfig::default());
9976        let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
9977        let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await;
9978        let traced = state
9979            .recorder
9980            .active_session_id()
9981            .expect("trace started a recording");
9982        // The agent ends the trace's recording and starts its own.
9983        let _ = state.recorder.stop();
9984        state.recorder.start("mine".to_string()).unwrap();
9985        let _ = call(&h, "trace", json!({"action": "stop"})).await;
9986        assert_eq!(
9987            state.recorder.active_session_id().as_deref(),
9988            Some("mine"),
9989            "trace stop must not end a recording it did not start (it started {traced})"
9990        );
9991    }
9992
9993    #[tokio::test]
9994    async fn restarting_a_trace_does_not_orphan_its_recording() {
9995        let state = state_with(PrivacyConfig::default());
9996        let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
9997        let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await;
9998        let first = state.recorder.active_session_id().unwrap();
9999        let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await;
10000        let second = state.recorder.active_session_id().unwrap();
10001        assert_ne!(
10002            first, second,
10003            "the first trace's recording was superseded, not orphaned"
10004        );
10005        let _ = call(&h, "trace", json!({"action": "stop"})).await;
10006        assert!(
10007            !state.recorder.is_recording(),
10008            "stop ends the second trace's recording"
10009        );
10010    }
10011
10012    #[tokio::test]
10013    async fn import_refuses_to_discard_an_active_recording() {
10014        let state = state_with(PrivacyConfig::default());
10015        state.recorder.start("live".to_string()).unwrap();
10016        let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
10017        let session = RecordedSession::new(
10018            "other".to_string(),
10019            chrono::Utc::now(),
10020            Vec::new(),
10021            Vec::new(),
10022        );
10023        let r = call(
10024            &h,
10025            "recording",
10026            json!({"action": "import", "session_json": serde_json::to_string(&session).unwrap()}),
10027        )
10028        .await;
10029        assert_eq!(r.is_error, Some(true), "{}", result_text(&r));
10030        assert_eq!(
10031            state.recorder.export().unwrap().id,
10032            "live",
10033            "active recording kept"
10034        );
10035    }
10036
10037    // ── introspect.contract_record / contract_check (audit #30, A2) ───────────
10038
10039    #[tokio::test]
10040    async fn contract_record_never_invokes_a_blocklisted_command() {
10041        let bridge = RecordingBridge::default();
10042        let state = state_with(blocking(&["delete_account"]));
10043        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
10044
10045        let r = call(
10046            &h,
10047            "introspect",
10048            json!({"action": "contract_record", "command": "delete_account", "args": {"confirm": true}}),
10049        )
10050        .await;
10051
10052        assert!(
10053            !bridge.invoked("delete_account"),
10054            "SIDE-EFFECT LEAK: contract_record invoked a blocklisted command (audit #30)"
10055        );
10056        assert_eq!(r.is_error, Some(true));
10057        assert!(
10058            result_text(&r).contains("blocked by privacy configuration"),
10059            "got: {}",
10060            result_text(&r)
10061        );
10062    }
10063
10064    #[tokio::test]
10065    async fn contract_record_does_invoke_an_allowed_command() {
10066        let state = state_with(PrivacyConfig::default());
10067        let bridge = RecordingBridge::answering(state.pending_evals.clone());
10068        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
10069
10070        let _ = call(
10071            &h,
10072            "introspect",
10073            json!({"action": "contract_record", "command": "get_settings"}),
10074        )
10075        .await;
10076
10077        assert!(
10078            bridge.invoked("get_settings"),
10079            "positive control failed: contract_record did not invoke an allowed command"
10080        );
10081    }
10082
10083    /// R4-PANIC1: the stored sample was cut with `&s[..4096]`, which panics when byte 4096
10084    /// falls inside a multi-byte character — any non-ASCII response over 4 KiB.
10085    #[tokio::test]
10086    async fn contract_record_samples_a_large_non_ascii_response_without_panicking() {
10087        let state = eval_state_with_timeout(2_000);
10088        let bridge = RecordingBridge::answering(state.pending_evals.clone());
10089        // The result text is `"ééé…"`: the opening quote puts every `é` on an odd byte
10090        // offset, so byte 4096 is the second byte of a character.
10091        let payload = "é".repeat(3000);
10092        bridge.answer_evals_with(
10093            &json!({"__victauri_ok": payload, "__victauri_type": "string"}).to_string(),
10094        );
10095        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
10096        let r = call(
10097            &h,
10098            "introspect",
10099            json!({"action": "contract_record", "command": "get_notes"}),
10100        )
10101        .await;
10102        let text = result_text(&r);
10103        assert_ne!(r.is_error, Some(true), "{text}");
10104        let baseline = state
10105            .contract_store
10106            .all()
10107            .into_iter()
10108            .find(|b| b.command == "get_notes")
10109            .expect("baseline recorded");
10110        assert!(
10111            baseline.sample.ends_with("...(truncated)"),
10112            "{}",
10113            baseline.sample
10114        );
10115        assert!(baseline.sample.len() <= 4096 + "...(truncated)".len());
10116    }
10117
10118    // ── pending-eval concurrency ceiling (audit: TOCTOU race) ────────────────
10119    #[tokio::test]
10120    async fn reserve_pending_is_a_hard_ceiling_under_concurrency() {
10121        // A check-then-insert (lock, read len(), unlock, …, lock, insert) races: many
10122        // concurrent callers all pass a STALE len() check before any inserts, blowing past
10123        // MAX_PENDING_EVALS. `reserve_pending` checks AND inserts under one lock, so the cap
10124        // is a true ceiling. Pre-fill to MAX-5, fire 50 concurrent reservations: EXACTLY 5
10125        // may succeed and the map must NEVER exceed the cap.
10126        let state = state_with(PrivacyConfig::default());
10127        {
10128            let mut p = state.pending_evals.lock().await;
10129            for i in 0..(MAX_PENDING_EVALS - 5) {
10130                let (tx, _rx) = tokio::sync::oneshot::channel();
10131                p.insert(format!("pre-{i}"), tx);
10132            }
10133        }
10134        let h = Arc::new(VictauriMcpHandler::new(
10135            state.clone(),
10136            Arc::new(RecordingBridge::default()),
10137        ));
10138        let mut tasks = Vec::new();
10139        for i in 0..50 {
10140            let h = h.clone();
10141            tasks.push(tokio::spawn(async move {
10142                let (tx, _rx) = tokio::sync::oneshot::channel();
10143                // keep rx and the slot alive until the reservation has been decided
10144                let slot = h.reserve_pending(&format!("c-{i}"), tx).await.ok();
10145                (slot, _rx)
10146            }));
10147        }
10148        let mut granted = 0;
10149        let mut keep = Vec::new();
10150        for t in tasks {
10151            let (slot, rx) = t.await.unwrap();
10152            if slot.is_some() {
10153                granted += 1;
10154            }
10155            keep.push((slot, rx)); // hold slots so reserved entries are not released
10156        }
10157        let len = state.pending_evals.lock().await.len();
10158        assert!(
10159            len <= MAX_PENDING_EVALS,
10160            "ceiling breached: {len} > {MAX_PENDING_EVALS}"
10161        );
10162        assert_eq!(
10163            granted, 5,
10164            "exactly the 5 free slots should have been reserved, got {granted}"
10165        );
10166        drop(keep);
10167    }
10168
10169    #[tokio::test]
10170    async fn contract_check_never_reinvokes_a_now_blocklisted_command() {
10171        // A baseline recorded before the command was blocked must not be re-invoked
10172        // once the operator adds it to the blocklist (audit #30).
10173        let bridge = RecordingBridge::default();
10174        let state = state_with(blocking(&["delete_account"]));
10175        state
10176            .contract_store
10177            .record(crate::introspection::ContractBaseline {
10178                command: "delete_account".to_string(),
10179                args: json!({}),
10180                shape: crate::introspection::JsonShape::from_value(&json!(true)),
10181                sample: "true".to_string(),
10182                recorded_at: chrono_now(),
10183            });
10184        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
10185
10186        let _ = call(&h, "introspect", json!({"action": "contract_check"})).await;
10187
10188        assert!(
10189            !bridge.invoked("delete_account"),
10190            "SIDE-EFFECT LEAK: contract_check re-invoked a now-blocklisted command (audit #30)"
10191        );
10192    }
10193
10194    // ── MCP resources honour the privacy gate (audit B1) ──────────────────────
10195
10196    #[test]
10197    fn resource_reads_are_gated_by_their_mirrored_capability() {
10198        // Resources bypass the tool dispatcher, so the read path must apply the same
10199        // gate. Disabling the capability a resource mirrors must block the resource.
10200        let cfg = PrivacyConfig {
10201            disabled_tools: HashSet::from([
10202                "logs.ipc".to_string(),
10203                "window.list".to_string(),
10204                "get_plugin_info".to_string(),
10205            ]),
10206            ..Default::default()
10207        };
10208        for uri in [
10209            RESOURCE_URI_IPC_LOG,
10210            RESOURCE_URI_WINDOWS,
10211            RESOURCE_URI_STATE,
10212        ] {
10213            let (_, cap) =
10214                resource_required_capability(uri).expect("resource maps to a capability");
10215            assert!(
10216                !cfg.is_tool_enabled(cap),
10217                "disabling capability {cap} must gate resource {uri} (audit B1)"
10218            );
10219            assert!(
10220                !resource_allowed(&cfg, uri),
10221                "disabling capability {cap} must gate resource {uri} (audit B1)"
10222            );
10223        }
10224        // Sanity: with nothing disabled, all three resources read.
10225        let full = PrivacyConfig::default();
10226        for uri in [
10227            RESOURCE_URI_IPC_LOG,
10228            RESOURCE_URI_WINDOWS,
10229            RESOURCE_URI_STATE,
10230        ] {
10231            assert!(full.is_tool_enabled(resource_required_capability(uri).unwrap().1));
10232            assert!(resource_allowed(&full, uri));
10233        }
10234    }
10235
10236    /// One JSON-RPC request to `/mcp` (legacy protocol, so the legacy subscribe method is
10237    /// routed); returns the response body text.
10238    async fn mcp_body(privacy: PrivacyConfig, method: &str, params: serde_json::Value) -> String {
10239        use http_body_util::BodyExt;
10240        use tower::ServiceExt;
10241        let app = build_app(state_with(privacy), Arc::new(RecordingBridge::default()));
10242        let body = json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params});
10243        let req = axum::http::Request::post("/mcp")
10244            .header("host", "127.0.0.1:7373")
10245            .header("content-type", "application/json")
10246            .header("accept", "application/json, text/event-stream")
10247            .header("mcp-protocol-version", "2025-06-18")
10248            .body(axum::body::Body::from(body.to_string()))
10249            .unwrap();
10250        let resp = tokio::time::timeout(std::time::Duration::from_secs(20), app.oneshot(req))
10251            .await
10252            .expect("an MCP request must be answered")
10253            .unwrap();
10254        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
10255        String::from_utf8_lossy(&bytes).into_owned()
10256    }
10257
10258    /// R4-NET3: a resource mirrors a tool action, and disabling the TOOL by its bare name
10259    /// (`disable_tools(["logs"])`) blocks every one of its actions — so it must block the
10260    /// resource too. The gate checked only the capability (`logs.ipc`), so the bare-name
10261    /// disable was ignored for resources (read and the legacy subscribe).
10262    #[tokio::test]
10263    async fn a_bare_tool_disable_also_blocks_its_resources() {
10264        for (disabled, uri) in [
10265            ("logs", RESOURCE_URI_IPC_LOG),
10266            ("window", RESOURCE_URI_WINDOWS),
10267            ("get_plugin_info", RESOURCE_URI_STATE),
10268        ] {
10269            let cfg = || PrivacyConfig {
10270                disabled_tools: HashSet::from([disabled.to_string()]),
10271                ..Default::default()
10272            };
10273            for method in ["resources/read", "resources/subscribe"] {
10274                let body = mcp_body(cfg(), method, json!({"uri": uri})).await;
10275                assert!(
10276                    body.contains("not permitted by the current privacy configuration"),
10277                    "disable_tools([{disabled:?}]) must block {method} {uri}: {body}"
10278                );
10279            }
10280        }
10281        // Positive control: nothing disabled, the resource reads.
10282        let body = mcp_body(
10283            PrivacyConfig::default(),
10284            "resources/read",
10285            json!({"uri": RESOURCE_URI_WINDOWS}),
10286        )
10287        .await;
10288        assert!(
10289            body.contains("\"contents\"") && !body.contains("not permitted"),
10290            "{body}"
10291        );
10292    }
10293
10294    // ── empty/whitespace auth token collapses to NO auth (audit B2) ───────────
10295
10296    #[tokio::test]
10297    async fn empty_auth_token_collapses_to_no_auth() {
10298        use http_body_util::BodyExt;
10299        use tower::ServiceExt;
10300
10301        for token in [Some(String::new()), Some("   ".to_string())] {
10302            let app = crate::mcp::server::build_app_full(
10303                state_with(PrivacyConfig::default()),
10304                Arc::new(RecordingBridge::default()),
10305                token.clone(),
10306                None,
10307            );
10308            let req = axum::extract::Request::builder()
10309                .uri("/info")
10310                .header("host", "127.0.0.1")
10311                .body(axum::body::Body::empty())
10312                .unwrap();
10313            let resp = app.oneshot(req).await.unwrap();
10314            assert_eq!(
10315                resp.status(),
10316                200,
10317                "/info must be reachable with empty token {token:?} (no auth layer)"
10318            );
10319            let bytes = resp.into_body().collect().await.unwrap().to_bytes();
10320            let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10321            assert_eq!(
10322                body["auth_required"],
10323                json!(false),
10324                "empty/whitespace token must report auth_required:false, not looks-protected-isnt (audit B2); token={token:?}"
10325            );
10326        }
10327    }
10328
10329    // ── app_info env allowlist drops secrets (audit #5/B3) ────────────────────
10330
10331    #[test]
10332    fn is_safe_env_key_drops_secrets_keeps_safe() {
10333        for secret in [
10334            "VICTAURI_AUTH_TOKEN",
10335            "TAURI_SIGNING_PRIVATE_KEY",
10336            "TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
10337            "CARGO_REGISTRY_TOKEN",
10338            "AWS_SECRET_ACCESS_KEY",
10339            "DATABASE_DSN",
10340            "GH_PAT",
10341        ] {
10342            assert!(
10343                !is_safe_env_key(secret),
10344                "{secret} is secret-shaped and must NOT be surfaced by app_info (audit #5)"
10345            );
10346        }
10347        for safe in [
10348            "HOME",
10349            "LANG",
10350            "TERM",
10351            "XDG_RUNTIME_DIR",
10352            "TAURI_ENV_PLATFORM",
10353        ] {
10354            assert!(
10355                is_safe_env_key(safe),
10356                "{safe} should be surfaced by app_info"
10357            );
10358        }
10359    }
10360}
10361
10362/// `screenshot` must refuse to "capture" a non-visible window.
10363///
10364/// Live-4DA dogfood (2026-06-16): requesting a hidden window (`label:"briefing"`)
10365/// returned a PNG that was actually the MAIN window's pixels — the OS capture path has
10366/// no live surface for an unmapped window, so it silently yields stale/foreign content.
10367/// The tool now checks visibility first and fails with an actionable message instead.
10368#[cfg(test)]
10369mod screenshot_visibility_tests {
10370    use super::*;
10371    use crate::bridge::WebviewBridge;
10372    use crate::privacy::PrivacyConfig;
10373    use std::collections::HashMap;
10374    use std::sync::Mutex as StdMutex;
10375    use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
10376
10377    fn window(label: &str, visible: bool) -> WindowState {
10378        WindowState::new(label.to_string())
10379            .with_title(label.to_string())
10380            .with_url("http://localhost/".to_string())
10381            .with_visible(visible)
10382            .with_focused(false)
10383            .with_maximized(false)
10384            .with_minimized(false)
10385            .with_fullscreen(false)
10386            .with_position(0, 0)
10387            .with_size(800, 600)
10388    }
10389
10390    /// A bridge with a configurable window set that RECORDS the label `get_native_handle`
10391    /// is asked for, then errs. Recording the label lets a test assert WHICH window the
10392    /// screenshot tool resolved to (the audit-P2 case: omitted label must resolve to a
10393    /// VISIBLE window, never hidden "main"); the error lets a test prove the visibility gate
10394    /// fired *before* the OS-handle path was reached.
10395    struct ConfigBridge {
10396        windows: Vec<WindowState>,
10397        handle_label: Arc<StdMutex<Option<Option<String>>>>,
10398    }
10399
10400    impl ConfigBridge {
10401        fn new(windows: Vec<WindowState>) -> Self {
10402            Self {
10403                windows,
10404                handle_label: Arc::new(StdMutex::new(None)),
10405            }
10406        }
10407        /// The label `get_native_handle` was called with, if it was reached.
10408        /// `Some(Some(l))` = called with label `l`; `Some(None)` = called with the default;
10409        /// `None` = never reached (gate short-circuited).
10410        fn requested_handle(&self) -> Option<Option<String>> {
10411            self.handle_label
10412                .lock()
10413                .unwrap_or_else(std::sync::PoisonError::into_inner)
10414                .clone()
10415        }
10416    }
10417
10418    impl WebviewBridge for ConfigBridge {
10419        fn eval_webview(&self, _l: Option<&str>, _s: &str) -> Result<(), String> {
10420            Err("no eval".to_string())
10421        }
10422        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
10423            match label {
10424                Some(l) => self
10425                    .windows
10426                    .iter()
10427                    .filter(|w| w.label == l)
10428                    .cloned()
10429                    .collect(),
10430                None => self.windows.clone(),
10431            }
10432        }
10433        fn list_window_labels(&self) -> Vec<String> {
10434            self.windows.iter().map(|w| w.label.clone()).collect()
10435        }
10436        fn get_native_handle(&self, l: Option<&str>) -> Result<isize, String> {
10437            *self
10438                .handle_label
10439                .lock()
10440                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(l.map(str::to_string));
10441            Err("native handle path reached".to_string())
10442        }
10443        fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
10444            Ok(String::new())
10445        }
10446        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
10447            Ok(())
10448        }
10449        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
10450            Ok(())
10451        }
10452        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
10453            Ok(())
10454        }
10455    }
10456
10457    fn handler_with(bridge: Arc<ConfigBridge>) -> VictauriMcpHandler {
10458        let state = Arc::new(VictauriState {
10459            event_log: EventLog::new(100),
10460            registry: CommandRegistry::new(),
10461            port: std::sync::atomic::AtomicU16::new(0),
10462            pending_evals: Arc::new(Mutex::new(HashMap::new())),
10463            recorder: EventRecorder::new(100),
10464            privacy: PrivacyConfig::default(),
10465            eval_timeout: std::time::Duration::from_millis(100),
10466            shutdown_tx: tokio::sync::watch::channel(false).0,
10467            started_at: std::time::Instant::now(),
10468            tool_invocations: std::sync::atomic::AtomicU64::new(0),
10469            allow_file_navigation: false,
10470            command_timings: crate::introspection::CommandTimings::new(),
10471            fault_registry: crate::introspection::FaultRegistry::new(),
10472            contract_store: crate::introspection::ContractStore::new(),
10473            startup_timeline: crate::introspection::StartupTimeline::new(),
10474            event_bus: crate::introspection::EventBusMonitor::default(),
10475            task_tracker: crate::introspection::TaskTracker::new(),
10476            bridge_ready: std::sync::atomic::AtomicBool::new(true),
10477            bridge_notify: tokio::sync::Notify::new(),
10478            db_search_paths: Vec::new(),
10479            screencast: Arc::new(crate::screencast::Screencast::default()),
10480            probes: crate::introspection::AppStateProbes::default(),
10481            drain_watermarks: crate::introspection::DrainWatermarks::default(),
10482            page_loads: crate::introspection::PageLoads::default(),
10483            server_runtime: std::sync::OnceLock::new(),
10484        });
10485        VictauriMcpHandler::new(state, bridge)
10486    }
10487
10488    fn error_text(r: &CallToolResult) -> String {
10489        r.content
10490            .iter()
10491            .filter_map(|c| match c {
10492                ContentBlock::Text(t) => Some(t.text.clone()),
10493                _ => None,
10494            })
10495            .collect::<Vec<_>>()
10496            .join("\n")
10497    }
10498
10499    #[tokio::test]
10500    async fn hidden_window_screenshot_errors_clearly() {
10501        let bridge = Arc::new(ConfigBridge::new(vec![
10502            window("main", true),
10503            window("briefing", false),
10504        ]));
10505        let h = handler_with(bridge.clone());
10506        let r = h
10507            .screenshot(Parameters(ScreenshotParams {
10508                window_label: Some("briefing".to_string()),
10509            }))
10510            .await;
10511        assert_eq!(r.is_error, Some(true), "hidden window must error");
10512        let text = error_text(&r);
10513        assert!(
10514            text.contains("not visible"),
10515            "error must explain the window is not visible, got: {text}"
10516        );
10517        assert!(
10518            bridge.requested_handle().is_none(),
10519            "must short-circuit BEFORE the OS-handle/capture path"
10520        );
10521    }
10522
10523    #[tokio::test]
10524    async fn visible_window_screenshot_proceeds_to_capture() {
10525        let bridge = Arc::new(ConfigBridge::new(vec![
10526            window("main", true),
10527            window("briefing", false),
10528        ]));
10529        let h = handler_with(bridge.clone());
10530        let r = h
10531            .screenshot(Parameters(ScreenshotParams {
10532                window_label: Some("main".to_string()),
10533            }))
10534            .await;
10535        // The gate must let a visible window THROUGH to the OS-handle path (which this mock
10536        // fails) — proving the gate only blocks hidden windows.
10537        let text = error_text(&r);
10538        assert!(
10539            text.contains("native handle path reached")
10540                || text.contains("cannot get window handle"),
10541            "a visible window must reach the capture path, got: {text}"
10542        );
10543        assert_eq!(
10544            bridge.requested_handle(),
10545            Some(Some("main".to_string())),
10546            "must capture the explicitly requested visible window"
10547        );
10548    }
10549
10550    // GPT audit (P2): `screenshot {}` with NO label previously resolved through
10551    // find_window(None), which prefers "main" UNCONDITIONALLY — so an app that hides main but
10552    // keeps a secondary window visible captured hidden main (the wrong-pixels class the PR
10553    // exists to prevent). The tool must now resolve its own VISIBLE target.
10554    #[tokio::test]
10555    async fn omitted_label_skips_hidden_main_for_visible_secondary() {
10556        let bridge = Arc::new(ConfigBridge::new(vec![
10557            window("main", false),     // main hidden
10558            window("secondary", true), // a different window is visible
10559        ]));
10560        let h = handler_with(bridge.clone());
10561        let r = h
10562            .screenshot(Parameters(ScreenshotParams { window_label: None }))
10563            .await;
10564        let text = error_text(&r);
10565        assert!(
10566            !text.contains("not visible") && !text.contains("no visible window"),
10567            "a visible secondary window exists — must NOT error, got: {text}"
10568        );
10569        assert_eq!(
10570            bridge.requested_handle(),
10571            Some(Some("secondary".to_string())),
10572            "omitted label must resolve to the VISIBLE secondary, never hidden main"
10573        );
10574    }
10575
10576    // Omitted label with a visible main present must still prefer "main".
10577    #[tokio::test]
10578    async fn omitted_label_prefers_visible_main() {
10579        let bridge = Arc::new(ConfigBridge::new(vec![
10580            window("main", true),
10581            window("secondary", true),
10582        ]));
10583        let h = handler_with(bridge.clone());
10584        let _ = h
10585            .screenshot(Parameters(ScreenshotParams { window_label: None }))
10586            .await;
10587        assert_eq!(
10588            bridge.requested_handle(),
10589            Some(Some("main".to_string())),
10590            "with a visible main present, omitted label must resolve to main"
10591        );
10592    }
10593
10594    // Every window hidden + omitted label: error clearly, never capture a hidden window.
10595    #[tokio::test]
10596    async fn all_hidden_omitted_label_errors() {
10597        let bridge = Arc::new(ConfigBridge::new(vec![
10598            window("main", false),
10599            window("briefing", false),
10600        ]));
10601        let h = handler_with(bridge.clone());
10602        let r = h
10603            .screenshot(Parameters(ScreenshotParams { window_label: None }))
10604            .await;
10605        assert_eq!(r.is_error, Some(true), "all-hidden must error");
10606        assert!(
10607            error_text(&r).contains("no visible window"),
10608            "error must say there is no visible window, got: {}",
10609            error_text(&r)
10610        );
10611        assert!(
10612            bridge.requested_handle().is_none(),
10613            "must NOT reach the OS-handle path when every window is hidden"
10614        );
10615    }
10616
10617    // An unknown explicit label is passed THROUGH to get_native_handle (which produces the
10618    // canonical "window not found"), not rejected as "not visible".
10619    #[tokio::test]
10620    async fn unknown_label_falls_through_to_handle_resolution() {
10621        let bridge = Arc::new(ConfigBridge::new(vec![window("main", true)]));
10622        let h = handler_with(bridge.clone());
10623        let r = h
10624            .screenshot(Parameters(ScreenshotParams {
10625                window_label: Some("ghost".to_string()),
10626            }))
10627            .await;
10628        assert!(
10629            !error_text(&r).contains("not visible"),
10630            "unknown label must not be reported as 'not visible'"
10631        );
10632        assert_eq!(
10633            bridge.requested_handle(),
10634            Some(Some("ghost".to_string())),
10635            "unknown label must be forwarded verbatim to get_native_handle"
10636        );
10637    }
10638}