Skip to main content

victauri_plugin/mcp/
mod.rs

1// This file is intentionally large (~3,400 lines). rmcp's `#[tool_router]`
2// macro requires every `#[tool]` method to live in a single `impl` block, so
3// splitting the handler across files would break tool registration. Parameter
4// structs are already factored into sub-modules (webview_params, window_params,
5// etc.) to keep this file focused on dispatch logic.
6
7pub(crate) mod authz;
8mod backend_params;
9mod bounded;
10mod compound_params;
11#[cfg(test)]
12mod drain_tests;
13mod hardening;
14mod helpers;
15mod introspection_params;
16mod other_params;
17pub(crate) mod page_json;
18mod rest;
19#[cfg(test)]
20mod robustness_tests;
21mod server;
22#[cfg(test)]
23mod tool_result_tests;
24mod verification_params;
25mod webview_params;
26mod window_params;
27
28use std::collections::{HashMap, HashSet};
29use std::sync::Arc;
30use std::sync::atomic::{AtomicBool, Ordering};
31
32use rmcp::handler::server::tool::ToolCallContext;
33use rmcp::handler::server::wrapper::Parameters;
34use rmcp::model::{
35    CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock,
36    InitializeResult, ListResourcesResult, ListToolsResult, PaginatedRequestParams,
37    ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, Resource,
38    ResourceContents, ServerCapabilities, SubscribeRequestParams, Tool, UnsubscribeRequestParams,
39};
40use rmcp::service::RequestContext;
41use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_router};
42use tokio::sync::Mutex;
43
44use crate::VictauriState;
45use crate::bridge::WebviewBridge;
46
47use helpers::{
48    RecoveryHint, build_ghost_report, ghost_ipc_outcomes_js, ghost_ipc_projection_js,
49    ipc_catalog_projection_js, ipc_timing_projection_js, ipc_timing_stats, js_string, json_result,
50    json_truthy, merge_command_catalog, missing_param, sanitize_css_color, sanitize_injected_css,
51    tool_disabled, tool_error, tool_error_with_hint, truncate_at_char_boundary, validate_url,
52};
53
54// MCP tool *parameter* types are an internal protocol surface: they are deserialized
55// from MCP/JSON, used only inside this crate's (private) tool methods, and change every
56// release as actions/fields are added. They are deliberately NOT part of the public API
57// (`pub(crate)`, not `pub use`), so adding a tool action or field is not a breaking change
58// and `cargo semver-checks` stays meaningful. Only `server::*` (build_app*,
59// VictauriMcpHandler) is the public MCP surface consumers actually use.
60pub(crate) use backend_params::*;
61pub(crate) use compound_params::*;
62/// Page-side probes run before trusted (OS-level) input. Internal: public only so the
63/// crate's own integration tests can run them in a JS engine. Not part of the supported API.
64#[doc(hidden)]
65pub use helpers::{trusted_click_probe_js, trusted_focus_probe_js};
66
67/// The IPC-log JS the tools send to the page. Internal plumbing, `pub` only so the jsdom suite
68/// (`tests/bridge_r5_tests.rs`) can run it against the real bridge — from a test binary of its
69/// own, since a seconds-long `node` child spawned from the library tests can inherit (and hold
70/// open) another test's server socket on Windows.
71#[doc(hidden)]
72pub mod ipc_log_js {
73    pub use super::helpers::{
74        ghost_ipc_outcomes_js, ipc_catalog_projection_js, ipc_timing_projection_js,
75    };
76    pub use super::{ipc_integrity_js, slow_ipc_js, trimmed_log_js};
77}
78pub(crate) use introspection_params::*;
79pub(crate) use other_params::{
80    AppStateParams, DiagnosticsParams, FindElementsParams, ResolveCommandParams,
81    SemanticAssertParams, WaitCondition, WaitForParams,
82};
83pub use server::*;
84pub(crate) use verification_params::*;
85pub(crate) use webview_params::*;
86pub(crate) use window_params::*;
87
88// ── MCP Handler ──────────────────────────────────────────────────────────────
89
90/// Maximum number of in-flight JavaScript eval requests. Prevents unbounded
91/// growth of the `pending_evals` map if callbacks are never resolved.
92pub(crate) const MAX_PENDING_EVALS: usize = 100;
93
94fn chrono_now() -> String {
95    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
96}
97
98/// Maximum length of JavaScript code accepted by the `eval_js` tool (1 MB).
99const MAX_EVAL_CODE_LEN: usize = 1_000_000;
100
101/// Maximum length of a JavaScript eval return value (5 MB).
102/// Results exceeding this are truncated to prevent memory exhaustion.
103const MAX_EVAL_RESULT_LEN: usize = 5_000_000;
104
105/// Default number of entries returned by IPC/network log tools when no explicit
106/// `limit` is given. Prevents busy apps (large logs) from exceeding the eval cap.
107const DEFAULT_LOG_LIMIT: usize = 100;
108
109/// Per-field byte cap applied to each IPC/network log entry before serialization.
110/// Large request/response bodies are truncated with a marker so the aggregate
111/// log stays well under [`MAX_EVAL_RESULT_LEN`] even on heavy-traffic apps.
112const MAX_LOG_FIELD_BYTES: usize = 4096;
113
114/// Hard cap on entries returned by `list_app_dir` (recursive). Without it a
115/// directory with millions of files (or a wide tree at max depth) would build an
116/// unbounded result Vec and blow the eval/output cap (audit B7). When hit, the
117/// listing stops and the response is marked `truncated: true`.
118const MAX_DIR_ENTRIES: usize = 10_000;
119/// Cap on directory entries `list_app_dir` EXAMINES (returned or not): with a `pattern` that
120/// matches nothing, [`MAX_DIR_ENTRIES`] never trips and the walk used to cover the whole tree.
121const MAX_DIR_VISITED: usize = 100_000;
122/// Wall-clock budget for one `list_app_dir` walk (slow or network filesystems).
123const MAX_DIR_WALK_TIME: std::time::Duration = std::time::Duration::from_secs(5);
124
125/// One bounded `list_app_dir` walk: stops (and reports `truncated`) at [`MAX_DIR_ENTRIES`]
126/// returned, [`MAX_DIR_VISITED`] examined, or [`MAX_DIR_WALK_TIME`].
127struct DirWalk {
128    /// The listing root, canonicalized ONCE (it used to be re-canonicalized per entry).
129    canon_base: std::path::PathBuf,
130    pattern: Option<String>,
131    max_depth: u32,
132    deadline: std::time::Instant,
133    max_visited: usize,
134    visited: usize,
135    truncated: bool,
136    entries: Vec<serde_json::Value>,
137}
138
139impl DirWalk {
140    fn out_of_budget(&mut self) -> bool {
141        if self.entries.len() >= MAX_DIR_ENTRIES
142            || self.visited >= self.max_visited
143            || std::time::Instant::now() >= self.deadline
144        {
145            self.truncated = true;
146        }
147        self.truncated
148    }
149
150    fn visit(&mut self, dir: &std::path::Path, base: &std::path::Path, depth: u32) {
151        let Ok(read_dir) = std::fs::read_dir(dir) else {
152            return;
153        };
154        for entry in read_dir.flatten() {
155            if self.out_of_budget() {
156                return;
157            }
158            self.visited += 1;
159            let path = entry.path();
160            if path.is_symlink() {
161                continue;
162            }
163            // `is_symlink` does not cover every redirecting filesystem object
164            // (notably Windows directory junctions/reparse points). Canonical
165            // containment is the actual boundary before metadata or recursion.
166            if !std::fs::canonicalize(&path).is_ok_and(|c| c.starts_with(&self.canon_base)) {
167                continue;
168            }
169            let name = entry.file_name().to_string_lossy().into_owned();
170            let is_dir = path.is_dir();
171            if let Some(pat) = self.pattern.as_deref()
172                && !is_dir
173                && !VictauriMcpHandler::matches_glob(&name, pat)
174            {
175                continue;
176            }
177            let relative = path
178                .strip_prefix(base)
179                .unwrap_or(&path)
180                .to_string_lossy()
181                .into_owned();
182            let meta = std::fs::metadata(&path).ok();
183
184            self.entries.push(serde_json::json!({
185                "name": name,
186                "path": relative,
187                "is_dir": is_dir,
188                "size": meta.as_ref().map(std::fs::Metadata::len),
189                "modified": meta.as_ref()
190                    .and_then(|m| m.modified().ok())
191                    .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
192                        .unwrap_or_default().as_secs()),
193            }));
194
195            if is_dir && depth < self.max_depth {
196                self.visit(&path, base, depth + 1);
197            }
198        }
199    }
200}
201
202/// `db_health` performs integrity checks and table counts against app-owned
203/// databases. Each size-dependent phase is bounded separately (see
204/// `database::db_health_report`) so a large or adversarial DB cannot hold a blocking
205/// worker indefinitely — and a slow phase degrades to a reported partial result instead
206/// of discarding the cheap ones.
207#[cfg(feature = "sqlite")]
208const DB_HEALTH_COUNT_BUDGET: std::time::Duration = std::time::Duration::from_secs(5);
209#[cfg(feature = "sqlite")]
210const DB_HEALTH_CHECK_BUDGET: std::time::Duration = std::time::Duration::from_secs(5);
211
212/// A timeout for an error message: whole seconds, or milliseconds when under a second (so a
213/// short per-call timeout never reads as "timed out after 0s").
214fn format_timeout(timeout: std::time::Duration) -> String {
215    if timeout < std::time::Duration::from_secs(1) {
216        format!("{}ms", timeout.as_millis())
217    } else {
218        format!("{}s", timeout.as_secs())
219    }
220}
221
222/// Error text for a window query the UI thread did not answer: a wedged/busy UI must never
223/// read as "no windows" or "window not found".
224fn ui_busy(error: &str) -> String {
225    format!("UI thread busy (dispatch timed out) - window state is unavailable, not empty: {error}")
226}
227
228/// The error result for a tool handler that panicked (see `bounded::CatchUnwind`).
229fn tool_panicked(tool: &str, panic: &str) -> CallToolResult {
230    tool_error(format!(
231        "internal error: the '{tool}' handler panicked ({panic}); the server is still running"
232    ))
233}
234
235/// Upper bound for `invoke_command`'s per-call `timeout_ms` (matches the eval-timeout ceiling).
236const MAX_INVOKE_TIMEOUT_MS: u64 = 300_000;
237
238/// How an eval call failed — typed, so a caller never infers from error text whether the code
239/// ran (e.g. whether the call is a command timing).
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241enum EvalFailureKind {
242    /// The code never ran: the call was refused or never delivered (saturated pending map, dead
243    /// bridge, failed injection, app exiting), or it did not parse.
244    NotSent,
245    /// Cut off in flight (timeout, app exit, window closed, page reload): it may or may not
246    /// have run.
247    Aborted,
248    /// The code ran in the page and threw, or ran but its result could not be returned.
249    Page,
250}
251
252/// A failed eval: what happened to the code, and the message for the agent.
253#[derive(Debug, Clone, PartialEq, Eq)]
254struct EvalFailure {
255    kind: EvalFailureKind,
256    message: String,
257}
258
259impl EvalFailure {
260    fn new(kind: EvalFailureKind, message: impl Into<String>) -> Self {
261        Self {
262            kind,
263            message: message.into(),
264        }
265    }
266
267    /// The code did not parse (reported by the bridge's post-wrapper check), so it never ran.
268    fn is_parse_error(&self) -> bool {
269        self.kind == EvalFailureKind::NotSent && self.message.starts_with(PARSE_ERROR_PREFIX)
270    }
271}
272
273/// How [`unwrap_eval_envelope`] reports code that did not parse.
274const PARSE_ERROR_PREFIX: &str = "JavaScript parse error:";
275
276/// How long `app_state` waits for an app-registered probe closure.
277///
278/// This used to be 1 s under `cfg(test)` for EVERY probe call, so a test of a panicking or a
279/// normal probe raced a deadline shorter than the scheduling tail of a loaded machine (a fresh
280/// blocking-pool thread plus the panic hook and unwind measured ~0.4 s at 2x CPU
281/// oversubscription, more in a full parallel suite) and flaked as "did not finish". Tests that
282/// exercise the deadline itself shorten [`VictauriMcpHandler::probe_timeout`] on their handler.
283const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
284
285/// App-registered probes allowed to run at once. A probe that hangs keeps its blocking thread
286/// past [`PROBE_TIMEOUT`]; the cap stops repeated calls to it from leaking a thread each.
287pub(crate) const MAX_CONCURRENT_PROBES: usize = 4;
288
289/// `read_app_file` reads allowed to run at once (a read blocked on a FIFO or slow device keeps
290/// its thread past [`READ_APP_FILE_TIMEOUT`]).
291pub(crate) const MAX_CONCURRENT_FILE_READS: usize = 4;
292
293/// How long `read_app_file` waits for its (bounded, at most 10 MB) read. Tests that exercise
294/// the deadline shorten [`VictauriMcpHandler::file_read_timeout`] (see [`PROBE_TIMEOUT`]).
295const READ_APP_FILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
296
297/// Distinct command names `CommandTimings` tracks (its private `MAX_TIMED_COMMANDS`, mirrored
298/// here — a test pins the two together). Once that many are tracked, new names are dropped and
299/// `introspect command_timings` reports `saturated: true`.
300pub(crate) const COMMAND_TIMINGS_CAP: usize = 1024;
301
302/// Upper bound for an injected `fault` delay (matches the `wait_for` ceiling).
303const MAX_FAULT_DELAY_MS: u64 = 120_000;
304
305/// Pixels between (and around) the cells of an `animation scrub` filmstrip.
306const FILMSTRIP_GAP: u32 = 4;
307
308/// Total time `recording stop` spends on its final flush of every window.
309const FINAL_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
310/// How often a slow eval re-checks that its target window still exists (first check after
311/// one interval, so fast evals never pay for it).
312const EVAL_WINDOW_WATCH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
313/// An abandoned `trace` auto-stops after this long (it captures a window every interval).
314const MAX_TRACE_DURATION: std::time::Duration = std::time::Duration::from_secs(30 * 60);
315/// Cap on the base64 bytes returned by one `trace frames` call.
316const MAX_TRACE_FRAMES_RESPONSE_BYTES: usize = 25 * 1024 * 1024;
317
318const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
319const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
320const RESOURCE_URI_STATE: &str = "victauri://state";
321
322/// SEP-2549 freshness hint for `tools/list` / `resources/list` results (5 minutes).
323/// Both lists are fixed for the process lifetime, so clients may cache them; a
324/// `notifications/tools/list_changed` (emitted by the CLI bridge on backend swap)
325/// still invalidates earlier. Conservative rather than "forever" so a client that
326/// only honors TTLs re-syncs within minutes of an app rebuild on the same port.
327const LIST_RESULT_TTL_MS: u64 = 300_000;
328
329/// Map an MCP resource URI to the tool call it mirrors: `(bare tool, capability)`.
330/// Resources are served outside the tool dispatcher, so `read_resource`/`subscribe` apply
331/// the same gate as a call of that tool action (audit B1) — including a disable of the
332/// whole tool by its bare name (R4-NET3). Returns `None` for an unknown URI (handled as
333/// not-found downstream).
334fn resource_required_capability(uri: &str) -> Option<(&'static str, &'static str)> {
335    match uri {
336        // Reading the IPC log via a resource == the `logs ipc` tool action.
337        RESOURCE_URI_IPC_LOG => Some(("logs", "logs.ipc")),
338        // Window states == the `window list` action.
339        RESOURCE_URI_WINDOWS => Some(("window", "window.list")),
340        // The state summary == reading plugin info.
341        RESOURCE_URI_STATE => Some(("get_plugin_info", "get_plugin_info")),
342        _ => None,
343    }
344}
345
346/// Whether the privacy configuration permits reading (or subscribing to) resource `uri`:
347/// exactly when it permits the tool call the resource mirrors. An unknown URI is not gated
348/// here (it is reported as not found).
349fn resource_allowed(privacy: &crate::privacy::PrivacyConfig, uri: &str) -> bool {
350    resource_required_capability(uri)
351        .is_none_or(|(tool, capability)| privacy.is_call_allowed(tool, capability))
352}
353
354const BRIDGE_VERSION: &str = env!("CARGO_PKG_VERSION");
355
356const SAFE_ENV_PREFIXES: &[&str] = &[
357    "HOME",
358    "USER",
359    "LANG",
360    "LC_",
361    "TERM",
362    "SHELL",
363    "DISPLAY",
364    "XDG_",
365    // Only Tauri's build-env namespace, NOT all of TAURI_ — the latter is an
366    // app-custom namespace that can hold secrets (audit #5).
367    "TAURI_ENV_",
368    "VICTAURI_",
369    "NODE_ENV",
370    "OS",
371    "HOSTNAME",
372    "PWD",
373    "SHLVL",
374    "LOGNAME",
375];
376
377/// Substrings that mark an env var as a secret. Even when a name matches a
378/// `SAFE_ENV_PREFIXES` entry it is dropped if it contains one of these — a prefix
379/// like `TAURI_`/`VICTAURI_` otherwise leaks `TAURI_SIGNING_PRIVATE_KEY`,
380/// `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, or `VICTAURI_AUTH_TOKEN` (audit #5).
381const SECRET_ENV_SUBSTRINGS: &[&str] = &[
382    "TOKEN",
383    "SECRET",
384    "PASS", // PASSWORD, PASSWD, PASSPHRASE
385    "PRIVATE",
386    "CREDENTIAL",
387    "APIKEY",
388    "AUTH",
389    "_KEY",
390    "DSN", // connection strings with embedded creds
391    "PAT", // personal access token
392    "JWT",
393    "BEARER",
394    "SESSION",
395    "COOKIE",
396    "SALT",
397    "CERT",
398    "SIGN", // signing keys/material
399    "LICENSE",
400];
401
402/// Whether an env var name is safe to surface via `app_info`: it must match a
403/// known-safe prefix AND not look like a secret (audit #5).
404fn is_safe_env_key(key: &str) -> bool {
405    let upper = key.to_uppercase();
406    SAFE_ENV_PREFIXES
407        .iter()
408        .any(|prefix| upper.starts_with(prefix))
409        && !SECRET_ENV_SUBSTRINGS.iter().any(|s| upper.contains(s))
410}
411
412/// MCP tool handler that dispatches tool calls to the webview bridge and state.
413#[derive(Clone)]
414pub struct VictauriMcpHandler {
415    state: Arc<VictauriState>,
416    bridge: Arc<dyn WebviewBridge>,
417    subscriptions: Arc<Mutex<HashSet<String>>>,
418    bridge_checked: Arc<AtomicBool>,
419    /// Window keys whose previous eval timed out. Retained only to annotate the
420    /// error on the *next* eval (the bridge is probed before every eval anyway).
421    timed_out_labels: Arc<Mutex<HashSet<String>>>,
422    /// Slots for running app probes ([`MAX_CONCURRENT_PROBES`]); held by the probe's thread.
423    probe_slots: Arc<tokio::sync::Semaphore>,
424    /// Slots for `read_app_file` reads ([`MAX_CONCURRENT_FILE_READS`]); held by the read's thread.
425    file_slots: Arc<tokio::sync::Semaphore>,
426    /// Deadline for one app probe ([`PROBE_TIMEOUT`]; tests of the deadline shorten it).
427    probe_timeout: std::time::Duration,
428    /// Deadline for one `read_app_file` read ([`READ_APP_FILE_TIMEOUT`]; likewise).
429    file_read_timeout: std::time::Duration,
430}
431
432#[tool_router]
433impl VictauriMcpHandler {
434    // ── Standalone Tools ────────────────────────────────────────────────────
435
436    #[tool(
437        description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.",
438        annotations(
439            read_only_hint = false,
440            destructive_hint = true,
441            idempotent_hint = false,
442            open_world_hint = false
443        )
444    )]
445    async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
446        if !self.state.privacy.is_tool_enabled("eval_js") {
447            return tool_disabled("eval_js");
448        }
449        if params.code.len() > MAX_EVAL_CODE_LEN {
450            return tool_error("code exceeds maximum length (1 MB)");
451        }
452        match self
453            .eval_with_return(&params.code, params.webview_label.as_deref())
454            .await
455        {
456            Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
457            Err(e) => tool_error(e),
458        }
459    }
460
461    #[tool(
462        description = "Get the DOM snapshot with stable ref handles. Default: compact accessible text (70-80%% fewer tokens). Set format=\"json\" for full tree. Returns tree + stale_refs (refs invalidated since last snapshot).",
463        annotations(
464            read_only_hint = true,
465            destructive_hint = false,
466            idempotent_hint = true,
467            open_world_hint = false
468        )
469    )]
470    async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
471        let format = params.format.unwrap_or(SnapshotFormat::Compact);
472        let format_str = match format {
473            SnapshotFormat::Compact => "compact",
474            SnapshotFormat::Json => "json",
475        };
476        let code = format!(
477            "return window.__VICTAURI__?.snapshot({})",
478            js_string(format_str)
479        );
480        self.eval_bridge(&code, params.webview_label.as_deref())
481            .await
482    }
483
484    #[tool(
485        description = "Search for elements by text, role, test_id, CSS selector (via `css` or `selector` param), or accessible name without a full snapshot. Returns lightweight matches with ref handles.",
486        annotations(
487            read_only_hint = true,
488            destructive_hint = false,
489            idempotent_hint = true,
490            open_world_hint = false
491        )
492    )]
493    async fn find_elements(
494        &self,
495        Parameters(params): Parameters<FindElementsParams>,
496    ) -> CallToolResult {
497        let mut parts: Vec<String> = Vec::new();
498        if let Some(t) = &params.text {
499            parts.push(format!("text: {}", js_string(t)));
500        }
501        if let Some(r) = &params.role {
502            parts.push(format!("role: {}", js_string(r)));
503        }
504        if let Some(tid) = &params.test_id {
505            parts.push(format!("test_id: {}", js_string(tid)));
506        }
507        if let Some(c) = params.css.as_ref().or(params.selector.as_ref()) {
508            parts.push(format!("css: {}", js_string(c)));
509        }
510        if let Some(n) = &params.name {
511            parts.push(format!("name: {}", js_string(n)));
512        }
513        if let Some(max) = params.max_results {
514            parts.push(format!("max_results: {max}"));
515        }
516        if let Some(t) = &params.tag {
517            parts.push(format!("tag: {}", js_string(t)));
518        }
519        if let Some(p) = &params.placeholder {
520            parts.push(format!("placeholder: {}", js_string(p)));
521        }
522        if let Some(a) = &params.alt {
523            parts.push(format!("alt: {}", js_string(a)));
524        }
525        if let Some(ta) = &params.title_attr {
526            parts.push(format!("title_attr: {}", js_string(ta)));
527        }
528        if let Some(l) = &params.label {
529            parts.push(format!("label: {}", js_string(l)));
530        }
531        if let Some(true) = params.exact {
532            parts.push("exact: true".to_string());
533        }
534        if let Some(e) = params.enabled {
535            parts.push(format!("enabled: {e}"));
536        }
537        let code = format!(
538            "return window.__VICTAURI__?.findElements({{ {} }})",
539            parts.join(", ")
540        );
541        match self
542            .eval_with_return(&code, params.webview_label.as_deref())
543            .await
544        {
545            Ok(result) => {
546                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
547                    && let Some(err) = parsed.get("error").and_then(|e| e.as_str())
548                {
549                    return tool_error(err);
550                }
551                CallToolResult::success(vec![ContentBlock::text(result)])
552            }
553            Err(e) => tool_error(e),
554        }
555    }
556
557    #[tool(
558        description = "Invoke a registered Tauri command via IPC, just like the frontend would. Goes through the real IPC pipeline so calls are logged and verifiable. Returns the command's result. Waits up to the eval timeout (30s) — pass `timeout_ms` (max 300000) for a legitimately slow command. Subject to privacy command filtering.",
559        annotations(
560            read_only_hint = false,
561            destructive_hint = true,
562            idempotent_hint = false,
563            open_world_hint = false
564        )
565    )]
566    async fn invoke_command(
567        &self,
568        Parameters(params): Parameters<InvokeCommandParams>,
569    ) -> CallToolResult {
570        if !self.state.privacy.is_invoke_allowed(&params.command) {
571            return tool_disabled("invoke_command");
572        }
573        if !self.state.privacy.is_command_allowed(&params.command) {
574            return tool_error(format!(
575                "command '{}' is blocked by privacy configuration",
576                params.command
577            ));
578        }
579
580        // ── Fault injection check ──
581        if let Some(fault) = self.state.fault_registry.check_and_trigger(&params.command) {
582            match fault {
583                crate::introspection::FaultType::Delay { delay_ms } => {
584                    tracing::info!(
585                        command = %params.command,
586                        delay_ms = delay_ms,
587                        "fault injection: delaying command"
588                    );
589                    tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
590                    // After delay, continue with normal execution below
591                }
592                crate::introspection::FaultType::Error { ref message } => {
593                    tracing::info!(
594                        command = %params.command,
595                        "fault injection: returning error"
596                    );
597                    return tool_error(format!(
598                        "[FAULT INJECTED] command '{}': {message}",
599                        params.command
600                    ));
601                }
602                crate::introspection::FaultType::Drop => {
603                    tracing::info!(
604                        command = %params.command,
605                        "fault injection: dropping response"
606                    );
607                    return CallToolResult::success(vec![ContentBlock::text("{}")]);
608                }
609                crate::introspection::FaultType::Corrupt => {
610                    tracing::info!(
611                        command = %params.command,
612                        "fault injection: corrupting response"
613                    );
614                    // Execute normally but mangle the response
615                    let args_json = params.args.unwrap_or(serde_json::json!({}));
616                    let args_str =
617                        serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
618                    let code = format!(
619                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
620                        js_string(&params.command)
621                    );
622                    if let Ok(result) = self
623                        .eval_with_return(&code, params.webview_label.as_deref())
624                        .await
625                    {
626                        let corrupted = format!(
627                            "{{\"__corrupted\":true,\"original_length\":{},\"fault\":\"corrupt\"}}",
628                            result.len()
629                        );
630                        return CallToolResult::success(vec![ContentBlock::text(corrupted)]);
631                    }
632                    return CallToolResult::success(vec![ContentBlock::text(
633                        "{\"__corrupted\":true,\"fault\":\"corrupt\",\"note\":\"original invocation also failed\"}",
634                    )]);
635                }
636            }
637        }
638
639        // ── Normal execution with timing ──
640        let start = std::time::Instant::now();
641        let args_json = params.args.unwrap_or(serde_json::json!({}));
642        let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
643        let code = format!(
644            "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
645            js_string(&params.command)
646        );
647        let timeout = params.timeout_ms.map_or(self.state.eval_timeout, |ms| {
648            std::time::Duration::from_millis(ms.clamp(1, MAX_INVOKE_TIMEOUT_MS))
649        });
650        let result = self
651            .eval_outcome(&code, params.webview_label.as_deref(), timeout)
652            .await;
653        let elapsed = start.elapsed();
654        // Only calls that reached the command are timings: one never sent (saturated, dead
655        // bridge, failed injection) measures nothing, and one cut off (timeout, app exit, closed
656        // window, reload) measures how long we waited — both skewed p95.
657        let ran = result
658            .as_ref()
659            .map_or_else(|f| f.kind == EvalFailureKind::Page, |_| true);
660        if ran {
661            self.state.command_timings.record(&params.command, elapsed);
662        }
663        let result = result.map_err(|f| f.message);
664
665        match result {
666            Ok(result) => {
667                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
668                    && let Some(err) = parsed.get("__error").and_then(|e| e.as_str())
669                {
670                    return tool_error(format!(
671                        "command '{}' returned error: {err}",
672                        params.command
673                    ));
674                }
675                CallToolResult::success(vec![ContentBlock::text(result)])
676            }
677            Err(e) if e.starts_with("eval timed out") => tool_error(format!(
678                "invoke_command failed: {e} If the command is legitimately slow, pass \
679                 `timeout_ms` (up to {MAX_INVOKE_TIMEOUT_MS})."
680            )),
681            Err(e) => tool_error(format!("invoke_command failed: {e}")),
682        }
683    }
684
685    #[tool(
686        description = "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Works on Windows (PrintWindow), macOS (CGWindowListCreateImage), and Linux X11/XWayland. Pure Wayland fails safely because its available fallback would capture the full desktop rather than the requested window. A hidden (non-visible) window has no on-screen surface to capture, so requesting one returns a clear error (show it first via `window` manage_action=show) rather than a stale or wrong-window image.",
687        annotations(
688            read_only_hint = true,
689            destructive_hint = false,
690            idempotent_hint = true,
691            open_world_hint = false
692        )
693    )]
694    async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
695        if !self.state.privacy.is_tool_enabled("screenshot") {
696            return tool_disabled("screenshot");
697        }
698        // Resolve the EXACT visible window BEFORE touching the OS handle — a silent
699        // wrong-window image is worse than a clear failure (see the helper for the history).
700        let target_label = match self.resolve_visible_capture_target(params.window_label.as_deref())
701        {
702            Ok(label) => label,
703            Err(e) => return tool_error(e),
704        };
705        match self.bridge.get_native_handle(Some(&target_label)) {
706            Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
707                Ok(png_bytes) => {
708                    use base64::Engine;
709                    let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
710                    CallToolResult::success(vec![ContentBlock::image(b64, "image/png")])
711                }
712                Err(e) => tool_error(format!("screenshot capture failed: {e}")),
713            },
714            Err(e) => tool_error(format!("cannot get window handle: {e}")),
715        }
716    }
717
718    #[tool(
719        description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.",
720        annotations(
721            read_only_hint = false,
722            destructive_hint = true,
723            idempotent_hint = false,
724            open_world_hint = false
725        )
726    )]
727    async fn verify_state(
728        &self,
729        Parameters(params): Parameters<VerifyStateParams>,
730    ) -> CallToolResult {
731        if !self.state.privacy.is_tool_enabled("eval_js") {
732            return tool_disabled("verify_state requires eval_js capability");
733        }
734        let code = expression_eval_code(&params.frontend_expr);
735        let frontend_json = match self
736            .eval_with_return(&code, params.webview_label.as_deref())
737            .await
738        {
739            Ok(result) => result,
740            Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
741        };
742
743        let frontend_state: serde_json::Value = match parse_expression_value(&frontend_json) {
744            Ok(v) => v,
745            Err(e) => {
746                return tool_error(format!(
747                    "frontend expression did not return valid JSON: {e}"
748                ));
749            }
750        };
751
752        let backend_state = if let Some(state) = params.backend_state {
753            state
754        } else if let Some(ref cmd) = params.backend_command {
755            // Gate on BOTH is_invoke_allowed and is_command_allowed, matching
756            // invoke_command and the contract/replay paths — backend_command
757            // previously checked only the blocklist (audit #30 follow-up).
758            if !self.state.privacy.is_invoke_allowed(cmd)
759                || !self.state.privacy.is_command_allowed(cmd)
760            {
761                return tool_error(format!(
762                    "command '{cmd}' is blocked by privacy configuration"
763                ));
764            }
765            let args = params.backend_args.unwrap_or(serde_json::json!({}));
766            let args_str = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
767            let invoke_code = format!(
768                "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
769                js_string(cmd)
770            );
771            match self
772                .eval_with_return(&invoke_code, params.webview_label.as_deref())
773                .await
774            {
775                Ok(result) => match serde_json::from_str(&result) {
776                    Ok(v) => v,
777                    Err(e) => {
778                        return tool_error(format!(
779                            "backend command '{cmd}' did not return valid JSON: {e}"
780                        ));
781                    }
782                },
783                Err(e) => {
784                    return tool_error(format!("failed to invoke backend command '{cmd}': {e}"));
785                }
786            }
787        } else {
788            return tool_error("either backend_state or backend_command must be provided");
789        };
790
791        let result = victauri_core::verify_state(frontend_state, backend_state);
792        json_result(&result)
793    }
794
795    #[tool(
796        description = "Detect ghost commands (frontend calls with no backend handler) by IPC OUTCOME, not by guessing from Victauri's registry. Returns: `confirmed_ghosts` = commands invoked that NEVER returned success and errored 'not found' — real missing-handler bugs, HIGH confidence and independent of whether the app uses #[inspectable]; `verified_handlers` = count of commands that returned success at least once (they provably HAVE a handler, so they are never flagged — this is why a real command like `set_language` is no longer a false positive); `frontend_only` = the WEAKER candidate tier (invoked, never observed succeeding, NOT a Tauri/plugin framework builtin, and absent from the introspection registry) — confirm against the app's `tauri::generate_handler!` before filing; `excluded_builtins` = framework `plugin:*` commands (never app ghosts); `registry_only` = registered commands never invoked (informational). The `reliability` field describes only `frontend_only`; `confirmed_ghosts` is high-confidence regardless. Reads the JS-side IPC interception log (ACCUMULATES all session traffic). For a clean signal scope with `since_ms` (e.g. 5000) — invoke the suspect action, then call this with `since_ms` — or `logs {action:'clear'}` then exercise the app.",
797        annotations(
798            read_only_hint = true,
799            destructive_hint = false,
800            idempotent_hint = true,
801            open_world_hint = false
802        )
803    )]
804    async fn detect_ghost_commands(
805        &self,
806        Parameters(params): Parameters<GhostCommandParams>,
807    ) -> CallToolResult {
808        // Project a per-command OUTCOME summary in JS ({command, ok, err}, deduped). Ghost
809        // detection is outcome-based (VIC-1): a command that returned success provably has a
810        // handler and is never a ghost; one that errored "not found" is a confirmed ghost.
811        // Aggregating per command keeps this tiny even on a busy app (avoids the eval cap).
812        // When `since_ms` is set, the projection time-windows to the current test's traffic.
813        let code = ghost_ipc_outcomes_js(params.since_ms);
814        let ipc_json = match self
815            .eval_with_return(&code, params.webview_label.as_deref())
816            .await
817        {
818            Ok(r) => r,
819            Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
820        };
821
822        let outcomes: Vec<crate::mcp::helpers::IpcOutcome> = match serde_json::from_str(&ipc_json) {
823            Ok(v) => v,
824            Err(e) => return tool_error(format!("failed to parse IPC log JSON: {e}")),
825        };
826
827        json_result(&build_ghost_report(&outcomes, &self.state.registry))
828    }
829
830    #[tool(
831        description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.",
832        annotations(
833            read_only_hint = true,
834            destructive_hint = false,
835            idempotent_hint = true,
836            open_world_hint = false
837        )
838    )]
839    async fn check_ipc_integrity(
840        &self,
841        Parameters(params): Parameters<IpcIntegrityParams>,
842    ) -> CallToolResult {
843        let threshold = params.stale_threshold_ms.unwrap_or(5000);
844        let code = ipc_integrity_js(threshold);
845        self.eval_bridge(&code, params.webview_label.as_deref())
846            .await
847    }
848
849    #[tool(
850        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.",
851        annotations(
852            read_only_hint = false,
853            destructive_hint = true,
854            idempotent_hint = false,
855            open_world_hint = false
856        )
857    )]
858    async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
859        let timeout_ms = params.timeout_ms.unwrap_or(10_000).min(120_000);
860        // Clamp BEFORE the value reaches JS: a poll longer than the wait is meaningless, and
861        // one past 2^31 ms overflows `setTimeout` (which then fires immediately, in a loop).
862        let poll = params.poll_ms.unwrap_or(200).clamp(20, timeout_ms.max(20));
863
864        // The `expression` and `event` conditions are awaited server-side (they
865        // poll the eval engine and the captured event bus respectively), so a
866        // fire-and-forget backend command can be awaited to true completion.
867        match params.condition {
868            WaitCondition::Expression => {
869                return self.wait_for_expression(&params, timeout_ms, poll).await;
870            }
871            WaitCondition::Event => {
872                return self.wait_for_event(&params, timeout_ms, poll).await;
873            }
874            _ => {}
875        }
876
877        let value = params
878            .value
879            .as_ref()
880            .map_or_else(|| "null".to_string(), |v| js_string(v));
881        let code = format!(
882            "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
883            js_string(params.condition.as_str())
884        );
885        let eval_timeout = std::time::Duration::from_millis(timeout_ms + 5000);
886        match self
887            .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
888            .await
889        {
890            Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
891            Err(e) => tool_error(e),
892        }
893    }
894
895    /// Poll a JS expression until truthy (or `== expected`), server-side.
896    ///
897    /// Level-triggered and race-free: each poll re-evaluates the expression via
898    /// the same engine as `eval_js`, so it may `await`. Eval errors are treated
899    /// as "not yet met" (the target may not exist during startup) and the last
900    /// error is surfaced on timeout.
901    async fn wait_for_expression(
902        &self,
903        params: &WaitForParams,
904        timeout_ms: u64,
905        poll_ms: u64,
906    ) -> CallToolResult {
907        if !self.state.privacy.is_tool_enabled("eval_js") {
908            return tool_disabled("wait_for(expression) requires eval_js capability");
909        }
910        let Some(expr) = params.value.as_deref().filter(|s| !s.is_empty()) else {
911            return missing_param("value", "wait_for(expression)");
912        };
913        let code = expression_eval_code(expr);
914        let start = std::time::Instant::now();
915        let deadline = start + std::time::Duration::from_millis(timeout_ms);
916        let poll = std::time::Duration::from_millis(poll_ms);
917        let mut last_value = serde_json::Value::Null;
918        let mut last_error: Option<String> = None;
919
920        loop {
921            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
922            let per_eval = remaining
923                .min(std::time::Duration::from_secs(15))
924                .max(std::time::Duration::from_secs(1));
925            match self
926                .eval_outcome(&code, params.webview_label.as_deref(), per_eval)
927                .await
928            {
929                // Code that does not parse can never start matching: fail now instead of
930                // polling to the timeout.
931                Err(f) if f.is_parse_error() => {
932                    return tool_error(format!("wait_for(expression): {}", f.message));
933                }
934                Ok(raw) => {
935                    let val = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null);
936                    let met = match &params.expected {
937                        Some(expected) => &val == expected,
938                        None => json_truthy(&val),
939                    };
940                    if met {
941                        return json_result(&serde_json::json!({
942                            "ok": true,
943                            "value": val,
944                            "elapsed_ms": start.elapsed().as_millis() as u64,
945                        }));
946                    }
947                    last_value = val;
948                }
949                Err(e) => last_error = Some(e.message),
950            }
951
952            if std::time::Instant::now() >= deadline {
953                return json_result(&serde_json::json!({
954                    "ok": false,
955                    "error": format!("timeout after {timeout_ms}ms"),
956                    "last_value": last_value,
957                    "last_error": last_error,
958                    "elapsed_ms": start.elapsed().as_millis() as u64,
959                }));
960            }
961            tokio::time::sleep(
962                poll.min(deadline.saturating_duration_since(std::time::Instant::now())),
963            )
964            .await;
965        }
966    }
967
968    /// Block until a named Tauri event appears on the captured event bus.
969    ///
970    /// Edge-triggered: matches the most recent event whose timestamp is no older
971    /// than `since_ms` before this call began, so an event fired in the gap
972    /// between `invoke_command` and this call is still caught. Polls the
973    /// event-bus ring buffer — no webview eval involved.
974    async fn wait_for_event(
975        &self,
976        params: &WaitForParams,
977        timeout_ms: u64,
978        poll_ms: u64,
979    ) -> CallToolResult {
980        let Some(name) = params.value.as_deref().filter(|s| !s.is_empty()) else {
981            return missing_param("value", "wait_for(event)");
982        };
983        let since_ms = params.since_ms.unwrap_or(2000);
984        let start = std::time::Instant::now();
985        let baseline = bounded::ms_ago(chrono::Utc::now(), since_ms);
986        let deadline = start + std::time::Duration::from_millis(timeout_ms);
987        let poll = std::time::Duration::from_millis(poll_ms);
988
989        loop {
990            // Search newest-first for a matching event no older than the baseline.
991            let matched = self.state.event_bus.events().into_iter().rev().find(|e| {
992                e.name == name
993                    && chrono::DateTime::parse_from_rfc3339(&e.timestamp)
994                        .map_or(true, |ts| ts.with_timezone(&chrono::Utc) >= baseline)
995            });
996            if let Some(ev) = matched {
997                return json_result(&serde_json::json!({
998                    "ok": true,
999                    "event": {
1000                        "name": ev.name,
1001                        "payload": ev.payload,
1002                        "timestamp": ev.timestamp,
1003                    },
1004                    "elapsed_ms": start.elapsed().as_millis() as u64,
1005                }));
1006            }
1007            if std::time::Instant::now() >= deadline {
1008                return json_result(&serde_json::json!({
1009                    "ok": false,
1010                    "error": format!("timeout after {timeout_ms}ms waiting for event '{name}'"),
1011                    "hint": "Ensure the app emits this Tauri event and Victauri captures it: \
1012                             custom events need VictauriBuilder::listen_events(&[\"…\"]); \
1013                             window-lifecycle events are captured automatically.",
1014                    "elapsed_ms": start.elapsed().as_millis() as u64,
1015                }));
1016            }
1017            // Never sleep past the deadline (a full poll used to overshoot a short timeout).
1018            tokio::time::sleep(
1019                poll.min(deadline.saturating_duration_since(std::time::Instant::now())),
1020            )
1021            .await;
1022        }
1023    }
1024
1025    #[tool(
1026        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.",
1027        annotations(
1028            read_only_hint = false,
1029            destructive_hint = true,
1030            idempotent_hint = false,
1031            open_world_hint = false
1032        )
1033    )]
1034    async fn assert_semantic(
1035        &self,
1036        Parameters(params): Parameters<SemanticAssertParams>,
1037    ) -> CallToolResult {
1038        if !self.state.privacy.is_tool_enabled("eval_js") {
1039            return tool_disabled("assert_semantic requires eval_js capability");
1040        }
1041        let code = expression_eval_code(&params.expression);
1042        let actual_json = match self
1043            .eval_with_return(&code, params.webview_label.as_deref())
1044            .await
1045        {
1046            Ok(result) => result,
1047            Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
1048        };
1049
1050        let actual: serde_json::Value = match parse_expression_value(&actual_json) {
1051            Ok(v) => v,
1052            Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
1053        };
1054
1055        let assertion =
1056            victauri_core::SemanticAssertion::new(params.label, params.condition, params.expected);
1057
1058        let result = victauri_core::evaluate_assertion(actual, &assertion);
1059        json_result(&result)
1060    }
1061
1062    #[tool(
1063        description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.",
1064        annotations(
1065            read_only_hint = true,
1066            destructive_hint = false,
1067            idempotent_hint = true,
1068            open_world_hint = false
1069        )
1070    )]
1071    async fn resolve_command(
1072        &self,
1073        Parameters(params): Parameters<ResolveCommandParams>,
1074    ) -> CallToolResult {
1075        let limit = params.limit.unwrap_or(5);
1076        let mut results = self.state.registry.resolve(&params.query);
1077        results.truncate(limit);
1078        json_result(&results)
1079    }
1080
1081    #[tool(
1082        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.",
1083        annotations(
1084            read_only_hint = true,
1085            destructive_hint = false,
1086            idempotent_hint = true,
1087            open_world_hint = false
1088        )
1089    )]
1090    async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
1091        let commands = match params.query {
1092            Some(q) => self.state.registry.search(&q),
1093            None => self.state.registry.list(),
1094        };
1095        json_result(&commands)
1096    }
1097
1098    #[tool(
1099        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).",
1100        annotations(
1101            read_only_hint = true,
1102            destructive_hint = false,
1103            idempotent_hint = true,
1104            open_world_hint = false
1105        )
1106    )]
1107    async fn app_state(&self, Parameters(params): Parameters<AppStateParams>) -> CallToolResult {
1108        let Some(name) = params.probe else {
1109            return json_result(&serde_json::json!({ "probes": self.state.probes.names() }));
1110        };
1111        if let Some(probe) = self.state.probes.get(&name) {
1112            // A probe is app code: run it off the async executor, bounded, and with a panic
1113            // boundary (it used to run inline on a tokio worker with neither). Its slot is
1114            // held by the probe's thread, so a hung probe that outlives the deadline still
1115            // counts against the cap and repeated calls cannot pile up leaked threads.
1116            let Ok(slot) = Arc::clone(&self.probe_slots).try_acquire_owned() else {
1117                return tool_error(format!(
1118                    "probe '{name}' not run: app probes are busy ({MAX_CONCURRENT_PROBES} \
1119                     still running — a probe that hangs keeps running past its timeout). \
1120                     Retry shortly."
1121                ));
1122            };
1123            match bounded::run_blocking_bounded(
1124                None,
1125                &format!("probe '{name}'"),
1126                self.probe_timeout,
1127                move || {
1128                    let _slot = slot;
1129                    Ok(probe())
1130                },
1131            )
1132            .await
1133            {
1134                Ok(value) => json_result(&value),
1135                Err(e) => tool_error(e),
1136            }
1137        } else {
1138            let available = self.state.probes.names();
1139            tool_error_with_hint(
1140                format!(
1141                    "unknown probe '{name}'. Available probes: {}",
1142                    if available.is_empty() {
1143                        "(none registered — add VictauriBuilder::probe(\"name\", ...))".to_string()
1144                    } else {
1145                        available.join(", ")
1146                    }
1147                ),
1148                RecoveryHint::CheckInput,
1149            )
1150        }
1151    }
1152
1153    #[tool(
1154        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.",
1155        annotations(
1156            read_only_hint = true,
1157            destructive_hint = false,
1158            idempotent_hint = true,
1159            open_world_hint = false
1160        )
1161    )]
1162    async fn get_memory_stats(&self) -> CallToolResult {
1163        let stats = crate::memory::current_stats();
1164        json_result(&stats)
1165    }
1166
1167    #[tool(
1168        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.",
1169        annotations(
1170            read_only_hint = true,
1171            destructive_hint = false,
1172            idempotent_hint = true,
1173            open_world_hint = false
1174        )
1175    )]
1176    async fn get_plugin_info(&self) -> CallToolResult {
1177        let disabled: Vec<&str> = self
1178            .state
1179            .privacy
1180            .disabled_tools
1181            .iter()
1182            .map(std::string::String::as_str)
1183            .collect();
1184        let blocklist: Vec<&str> = self
1185            .state
1186            .privacy
1187            .command_blocklist
1188            .iter()
1189            .map(std::string::String::as_str)
1190            .collect();
1191        let allowlist: Option<Vec<&str>> = self
1192            .state
1193            .privacy
1194            .command_allowlist
1195            .as_ref()
1196            .map(|s| s.iter().map(std::string::String::as_str).collect());
1197        let all_tools = Self::tool_router().list_all();
1198        let enabled_tools: Vec<&str> = all_tools
1199            .iter()
1200            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
1201            .map(|t| t.name.as_ref())
1202            .collect();
1203
1204        // Host-app identity: lets an agent verify on its FIRST call that it reached the
1205        // intended app (not another Victauri instance sharing the discovery port).
1206        let app_cfg = self.bridge.tauri_config();
1207        let result = serde_json::json!({
1208            "version": env!("CARGO_PKG_VERSION"),
1209            "bridge_version": BRIDGE_VERSION,
1210            "port": self.state.port.load(Ordering::Relaxed),
1211            "app": {
1212                "identifier": app_cfg.get("identifier"),
1213                "product_name": app_cfg.get("product_name"),
1214            },
1215            "tools": {
1216                "total": all_tools.len(),
1217                "enabled": enabled_tools.len(),
1218                "enabled_list": enabled_tools,
1219                "disabled_list": disabled,
1220            },
1221            "commands": {
1222                "allowlist": allowlist,
1223                "blocklist": blocklist,
1224            },
1225            "privacy": {
1226                "profile": self.state.privacy.profile.to_string(),
1227                "redaction_enabled": self.state.privacy.redaction_enabled,
1228            },
1229            "capacities": {
1230                "event_log": self.state.event_log.capacity(),
1231                "eval_timeout_secs": self.state.eval_timeout.as_secs(),
1232            },
1233            "registered_commands": self.state.registry.count(),
1234            "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
1235            "uptime_secs": self.state.started_at.elapsed().as_secs(),
1236        });
1237        json_result(&result)
1238    }
1239
1240    #[tool(
1241        description = "Run environment diagnostics: detect service workers (break IPC interception), closed shadow DOM (invisible to snapshots), iframes (bridge absent), large DOM warnings, and CSP status. Call this first when connecting to an unfamiliar app.",
1242        annotations(
1243            read_only_hint = true,
1244            destructive_hint = false,
1245            idempotent_hint = true,
1246            open_world_hint = false
1247        )
1248    )]
1249    async fn get_diagnostics(
1250        &self,
1251        Parameters(params): Parameters<DiagnosticsParams>,
1252    ) -> CallToolResult {
1253        self.eval_bridge(
1254            "return window.__VICTAURI__?.getDiagnostics()",
1255            params.webview_label.as_deref(),
1256        )
1257        .await
1258    }
1259
1260    // ── Backend Access Tools ───────────────────────────────────────────────
1261
1262    #[tool(
1263        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.",
1264        annotations(
1265            read_only_hint = true,
1266            destructive_hint = false,
1267            idempotent_hint = true,
1268            open_world_hint = false
1269        )
1270    )]
1271    async fn app_info(&self) -> CallToolResult {
1272        let config = self.bridge.tauri_config();
1273
1274        let data_dir = self.bridge.app_data_dir().ok();
1275        let config_dir = self.bridge.app_config_dir().ok();
1276        let log_dir = self.bridge.app_log_dir().ok();
1277        let local_data_dir = self.bridge.app_local_data_dir().ok();
1278
1279        let env_vars: std::collections::BTreeMap<String, String> = std::env::vars()
1280            .filter(|(k, _)| is_safe_env_key(k))
1281            .collect();
1282
1283        // Enumerate every database candidate across ALL roots (configured db_search_paths
1284        // + every OS app dir), each tagged with size, whether it is a WebView/engine
1285        // internal store, and whether it is the one `query_db` would auto-select. This lets
1286        // an agent see and disambiguate the real app DB instead of guessing (audit /
1287        // red-team "wrong DB" finding — `app_info.databases` previously only walked
1288        // data_dir and returned bare relative names).
1289        #[cfg(feature = "sqlite")]
1290        let databases: Vec<serde_json::Value> = {
1291            let mut all_dirs: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
1292            for d in [
1293                data_dir.as_ref(),
1294                config_dir.as_ref(),
1295                log_dir.as_ref(),
1296                local_data_dir.as_ref(),
1297            ]
1298            .into_iter()
1299            .flatten()
1300            {
1301                all_dirs.push(d.clone());
1302            }
1303            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
1304                all_dirs.clone()
1305            } else {
1306                self.state.db_search_paths.clone()
1307            };
1308            let selected = crate::database::select_app_database(&select_dirs).ok();
1309            crate::database::classify_databases(&all_dirs)
1310                .into_iter()
1311                .map(|c| {
1312                    serde_json::json!({
1313                        "path": c.path.to_string_lossy(),
1314                        "size_bytes": c.size_bytes,
1315                        "webview_internal": c.webview_internal,
1316                        "selected": selected.as_ref() == Some(&c.path),
1317                    })
1318                })
1319                .collect()
1320        };
1321
1322        #[cfg(not(feature = "sqlite"))]
1323        let databases: Vec<serde_json::Value> = Vec::new();
1324
1325        let result = serde_json::json!({
1326            "config": config,
1327            "paths": {
1328                "data": data_dir.as_ref().map(|p| p.to_string_lossy()),
1329                "config": config_dir.as_ref().map(|p| p.to_string_lossy()),
1330                "log": log_dir.as_ref().map(|p| p.to_string_lossy()),
1331                "local_data": local_data_dir.as_ref().map(|p| p.to_string_lossy()),
1332            },
1333            "databases": databases,
1334            "env": env_vars,
1335            "process": {
1336                "pid": std::process::id(),
1337                "arch": std::env::consts::ARCH,
1338                "os": std::env::consts::OS,
1339                "family": std::env::consts::FAMILY,
1340            },
1341        });
1342        json_result(&result)
1343    }
1344
1345    #[tool(
1346        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.",
1347        annotations(
1348            read_only_hint = true,
1349            destructive_hint = false,
1350            idempotent_hint = true,
1351            open_world_hint = false
1352        )
1353    )]
1354    async fn list_app_dir(
1355        &self,
1356        Parameters(params): Parameters<ListAppDirParams>,
1357    ) -> CallToolResult {
1358        let base = match self.resolve_app_dir(params.directory) {
1359            Ok(d) => d,
1360            Err(e) => return tool_error(e),
1361        };
1362
1363        // Lexical traversal guard BEFORE any filesystem access: a `..` or absolute sub-path
1364        // is rejected as traversal up front.
1365        if let Some(sub) = params.path.as_deref()
1366            && let Err(e) = Self::lexical_safe(std::path::Path::new(sub))
1367        {
1368            return tool_error(e);
1369        }
1370        // Every filesystem call below is synchronous and unbounded in the directory's size, so
1371        // the whole walk runs on the blocking pool (it used to run on an async worker).
1372        let walk = tokio::task::spawn_blocking(move || Self::list_app_dir_blocking(&base, &params));
1373        match walk.await {
1374            Ok(Ok(listing)) => json_result(&listing),
1375            Ok(Err(e)) => tool_error(e),
1376            Err(e) => tool_error(format!("directory listing task failed: {e}")),
1377        }
1378    }
1379
1380    fn list_app_dir_blocking(
1381        base: &std::path::Path,
1382        params: &ListAppDirParams,
1383    ) -> Result<serde_json::Value, String> {
1384        let sub = params.path.clone().unwrap_or_default();
1385        let target = base.join(&sub);
1386        // A missing directory is a normal, non-error result — unless the path escapes the
1387        // base through a symlink, which is refused whether or not its target exists (so the
1388        // listing is no oracle for paths outside the base).
1389        if !Self::contained_or_missing(base, &target)? {
1390            return Ok(serde_json::json!({
1391                "base": base.to_string_lossy(),
1392                "path": sub,
1393                "exists": false,
1394                "entries": [],
1395                "count": 0,
1396            }));
1397        }
1398        let canon_base = std::fs::canonicalize(base)
1399            .map_err(|e| format!("cannot resolve base directory: {e}"))?;
1400
1401        let mut walk = DirWalk {
1402            canon_base,
1403            pattern: params.pattern.clone(),
1404            max_depth: params.max_depth.unwrap_or(1).min(5),
1405            deadline: std::time::Instant::now() + MAX_DIR_WALK_TIME,
1406            max_visited: MAX_DIR_VISITED,
1407            visited: 0,
1408            truncated: false,
1409            entries: Vec::new(),
1410        };
1411        walk.visit(&target, base, 0);
1412
1413        Ok(serde_json::json!({
1414            "base": base.to_string_lossy(),
1415            "path": sub,
1416            "exists": true,
1417            "count": walk.entries.len(),
1418            "entries": walk.entries,
1419            "truncated": walk.truncated,
1420        }))
1421    }
1422
1423    #[tool(
1424        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.",
1425        annotations(
1426            read_only_hint = true,
1427            destructive_hint = false,
1428            idempotent_hint = true,
1429            open_world_hint = false
1430        )
1431    )]
1432    async fn read_app_file(
1433        &self,
1434        Parameters(params): Parameters<ReadAppFileParams>,
1435    ) -> CallToolResult {
1436        let base = match self.resolve_app_dir(params.directory) {
1437            Ok(d) => d,
1438            Err(e) => return tool_error(e),
1439        };
1440
1441        // Lexical traversal guard FIRST — before the existence check — so a
1442        // traversal attempt (`..` / absolute) is rejected as traversal rather
1443        // than leaking whether the out-of-tree target exists via "file not
1444        // found". `safe_within` (which canonicalizes) stays below as
1445        // defense-in-depth for real files.
1446        if let Err(e) = Self::lexical_safe(std::path::Path::new(&params.path)) {
1447            return tool_error(e);
1448        }
1449        let target = base.join(&params.path);
1450        // Refused as traversal whether or not a symlinked-out target exists (no oracle).
1451        match Self::contained_or_missing(&base, &target) {
1452            Ok(true) => {}
1453            Ok(false) => return tool_error(format!("file not found: {}", params.path)),
1454            Err(e) => return tool_error(e),
1455        }
1456        if let Err(e) = Self::safe_within(&base, &target) {
1457            return tool_error(e);
1458        }
1459        if !target.is_file() {
1460            return tool_error(format!("not a file: {}", params.path));
1461        }
1462
1463        let max_bytes = params.max_bytes.unwrap_or(1_048_576).min(10_485_760);
1464
1465        // Open the CANONICAL validated path, and do the blocking file IO on the blocking pool.
1466        // Doing sync `std::fs` IO directly in this async fn could stall the executor thread if a
1467        // regular file were swapped (between the `safe_within` check and the open) for a FIFO or
1468        // slow device whose `read_to_end` blocks; opening the canonical path also closes the
1469        // trivial validate-lexical / open-lexical symlink-swap window.
1470        let canonical = match std::fs::canonicalize(&target) {
1471            Ok(c) => c,
1472            Err(e) => return tool_error(format!("cannot resolve path: {e}")),
1473        };
1474        let (mut bytes, original_size, modified) =
1475            match self.read_regular_file_bounded(canonical, max_bytes).await {
1476                Ok(v) => v,
1477                Err(e) => return tool_error(format!("failed to read file: {e}")),
1478            };
1479        let truncated = bytes.len() > max_bytes;
1480        if truncated {
1481            bytes.truncate(max_bytes);
1482        }
1483
1484        let file_info = serde_json::json!({
1485            "path": params.path,
1486            "size": original_size,
1487            "truncated": truncated,
1488            "modified": modified,
1489        });
1490
1491        if params.binary == Some(true) {
1492            use base64::Engine;
1493            let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1494            json_result(&serde_json::json!({
1495                "file": file_info,
1496                "encoding": "base64",
1497                "content": b64,
1498            }))
1499        } else {
1500            // Truncation can split a multi-byte character; that is not a non-UTF-8 file.
1501            // `error_len() == None` means the bytes end mid-character, so drop the partial
1502            // character instead of returning the whole read as base64.
1503            if truncated
1504                && let Err(e) = std::str::from_utf8(&bytes)
1505                && e.error_len().is_none()
1506            {
1507                bytes.truncate(e.valid_up_to());
1508            }
1509            match String::from_utf8(bytes) {
1510                Ok(text) => json_result(&serde_json::json!({
1511                    "file": file_info,
1512                    "encoding": "utf-8",
1513                    "content": text,
1514                })),
1515                Err(e) => {
1516                    use base64::Engine;
1517                    let bytes = e.into_bytes();
1518                    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1519                    json_result(&serde_json::json!({
1520                        "file": file_info,
1521                        "encoding": "base64",
1522                        "note": "file is not valid UTF-8, returning base64",
1523                        "content": b64,
1524                    }))
1525                }
1526            }
1527        }
1528    }
1529
1530    #[tool(
1531        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.",
1532        annotations(
1533            read_only_hint = true,
1534            destructive_hint = false,
1535            idempotent_hint = true,
1536            open_world_hint = false
1537        )
1538    )]
1539    async fn query_db(&self, Parameters(params): Parameters<QueryDbParams>) -> CallToolResult {
1540        // query_db is ALWAYS registered as a tool so the rmcp `#[tool_router]` macro
1541        // compiles with `default-features = false` (a consumer that drops the heavy
1542        // rusqlite C dependency). The actual SQLite implementation only exists with the
1543        // `sqlite` feature; without it, return a clear, actionable error.
1544        #[cfg(feature = "sqlite")]
1545        {
1546            self.query_db_impl(params).await
1547        }
1548        #[cfg(not(feature = "sqlite"))]
1549        {
1550            let _ = params;
1551            tool_error(
1552                "query_db is unavailable: this build was compiled without the 'sqlite' \
1553                 feature (default-features = false). Re-enable the 'sqlite' feature to use it.",
1554            )
1555        }
1556    }
1557
1558    /// Real `query_db` implementation — compiled only with the `sqlite` feature.
1559    #[cfg(feature = "sqlite")]
1560    async fn query_db_impl(&self, params: QueryDbParams) -> CallToolResult {
1561        // A refused query is refused for what it is, before any database is looked up: the
1562        // error used to depend on whether the app HAS a database ("no application database"
1563        // for a DELETE on an app without one), and it touched the filesystem for nothing.
1564        if let Err(e) = crate::database::validate_query(&params.query) {
1565            return tool_error(e);
1566        }
1567        let data_dir = match self.bridge.app_data_dir() {
1568            Ok(d) => d,
1569            Err(e) => return tool_error(format!("cannot access app data directory: {e}")),
1570        };
1571
1572        let search_dirs = self.db_roots();
1573
1574        let db_path = if let Some(ref requested_path) = params.path {
1575            match Self::resolve_existing_db_path(&search_dirs, requested_path) {
1576                Ok(path) => path,
1577                Err(e) => return tool_error(e),
1578            }
1579        } else {
1580            // Auto-select the application DB. When db_search_paths is configured it is
1581            // EXCLUSIVE — never fall back to OS app dirs (which hold WebView internals),
1582            // so a configured-but-empty root yields a clear error instead of silently
1583            // querying the wrong database. WebView/browser-engine internal stores are
1584            // excluded and the largest remaining candidate wins (audit / red-team "wrong
1585            // DB" finding).
1586            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
1587                search_dirs.clone()
1588            } else {
1589                self.state.db_search_paths.clone()
1590            };
1591            match crate::database::select_app_database(&select_dirs) {
1592                Ok(p) => p,
1593                Err(e) => return tool_error(e),
1594            }
1595        };
1596
1597        let db_display = db_path
1598            .strip_prefix(&data_dir)
1599            .unwrap_or(&db_path)
1600            .to_string_lossy()
1601            .into_owned();
1602        let bind_params = params.params.unwrap_or_default();
1603        let query = params.query;
1604        let max_rows = params.max_rows;
1605
1606        match bounded::run_blocking_bounded(
1607            Some(&bounded::DB_SLOTS),
1608            "database query",
1609            crate::database::QUERY_TIMEOUT + bounded::BLOCKING_DEADLINE_SLACK,
1610            move || crate::database::query(&db_path, &query, &bind_params, max_rows),
1611        )
1612        .await
1613        {
1614            Ok(mut result) => {
1615                if let Some(obj) = result.as_object_mut() {
1616                    obj.insert("database".to_string(), serde_json::json!(db_display));
1617                }
1618                json_result(&result)
1619            }
1620            Err(e) => tool_error(e),
1621        }
1622    }
1623
1624    /// Roots a `query_db` / `db_health` database `path` resolves against, in precedence
1625    /// order: configured `db_search_paths` (so a configured app DB beats incidental ones such
1626    /// as `WebView` internals), then the app's data, config, local-data and log directories.
1627    /// De-duplicated keeping the FIRST occurrence: a relative path present under two roots
1628    /// always resolves to the same file (a `HashSet` made that nondeterministic), and both
1629    /// tools search the same places (`db_health` used to skip the log directory).
1630    #[cfg(feature = "sqlite")]
1631    fn db_roots(&self) -> Vec<std::path::PathBuf> {
1632        let mut roots: Vec<std::path::PathBuf> = Vec::new();
1633        let app_dirs = [
1634            self.bridge.app_data_dir(),
1635            self.bridge.app_config_dir(),
1636            self.bridge.app_local_data_dir(),
1637            self.bridge.app_log_dir(),
1638        ];
1639        let candidates = self
1640            .state
1641            .db_search_paths
1642            .iter()
1643            .cloned()
1644            .chain(app_dirs.into_iter().filter_map(Result::ok));
1645        for dir in candidates {
1646            if !roots.contains(&dir) {
1647                roots.push(dir);
1648            }
1649        }
1650        roots
1651    }
1652
1653    // ── Compound Tools ──────────────────────────────────────────────────────
1654
1655    #[tool(
1656        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].",
1657        annotations(
1658            read_only_hint = false,
1659            destructive_hint = false,
1660            idempotent_hint = false,
1661            open_world_hint = false
1662        )
1663    )]
1664    async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
1665        if !self.state.privacy.is_tool_enabled("interact") {
1666            return tool_disabled("interact");
1667        }
1668        match params.action {
1669            InteractAction::Click => {
1670                if !self.state.privacy.is_tool_enabled("interact.click") {
1671                    return tool_disabled("interact.click");
1672                }
1673                let Some(ref_id) = &params.ref_id else {
1674                    return missing_param("ref_id", "click");
1675                };
1676                if params.trusted.unwrap_or(false) {
1677                    // Resolve the click point in the top window's viewport (frame offsets
1678                    // added), refusing a disabled/hidden/covered/off-screen element — a real
1679                    // OS click lands on whatever is on screen there — then deliver it.
1680                    let probe = trusted_click_probe_js(ref_id);
1681                    let raw = match self
1682                        .eval_with_return(&probe, params.webview_label.as_deref())
1683                        .await
1684                    {
1685                        Ok(r) => r,
1686                        Err(e) => return tool_error(e),
1687                    };
1688                    let point = serde_json::from_str::<serde_json::Value>(&raw).unwrap_or_default();
1689                    if let Some(why) = point.get("error").and_then(serde_json::Value::as_str) {
1690                        return tool_error_with_hint(
1691                            format!(
1692                                "trusted click on {ref_id} refused: {why} — no OS click was sent"
1693                            ),
1694                            RecoveryHint::CheckInput,
1695                        );
1696                    }
1697                    let (Some(x), Some(y)) = (
1698                        point.get("x").and_then(serde_json::Value::as_f64),
1699                        point.get("y").and_then(serde_json::Value::as_f64),
1700                    ) else {
1701                        return tool_error_with_hint(
1702                            format!("ref not found: {ref_id}"),
1703                            RecoveryHint::CheckInput,
1704                        );
1705                    };
1706                    if !(x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0) {
1707                        return tool_error_with_hint(
1708                            format!(
1709                                "trusted click on {ref_id} refused: the page reported an \
1710                                 unusable click point ({x}, {y}) — no OS click was sent"
1711                            ),
1712                            RecoveryHint::CheckInput,
1713                        );
1714                    }
1715                    let bridge = self.bridge.clone();
1716                    let label = params.webview_label.clone();
1717                    let native = tokio::task::spawn_blocking(move || {
1718                        bridge.native_click(label.as_deref(), x, y)
1719                    })
1720                    .await
1721                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1722                    return match native {
1723                        Ok(()) => json_result(
1724                            &serde_json::json!({"ok": true, "trusted": true, "x": x, "y": y}),
1725                        ),
1726                        Err(e) => tool_error(e),
1727                    };
1728                }
1729                let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
1730                self.eval_page_action(&code, params.webview_label.as_deref())
1731                    .await
1732            }
1733            InteractAction::DoubleClick => {
1734                if !self.state.privacy.is_tool_enabled("interact.double_click") {
1735                    return tool_disabled("interact.double_click");
1736                }
1737                let Some(ref_id) = &params.ref_id else {
1738                    return missing_param("ref_id", "double_click");
1739                };
1740                let code = format!(
1741                    "return window.__VICTAURI__?.doubleClick({})",
1742                    js_string(ref_id)
1743                );
1744                self.eval_page_action(&code, params.webview_label.as_deref())
1745                    .await
1746            }
1747            InteractAction::Hover => {
1748                if !self.state.privacy.is_tool_enabled("interact.hover") {
1749                    return tool_disabled("interact.hover");
1750                }
1751                let Some(ref_id) = &params.ref_id else {
1752                    return missing_param("ref_id", "hover");
1753                };
1754                let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
1755                self.eval_page_action(&code, params.webview_label.as_deref())
1756                    .await
1757            }
1758            InteractAction::Focus => {
1759                if !self.state.privacy.is_tool_enabled("interact.focus") {
1760                    return tool_disabled("interact.focus");
1761                }
1762                let Some(ref_id) = &params.ref_id else {
1763                    return missing_param("ref_id", "focus");
1764                };
1765                let code = format!(
1766                    "return window.__VICTAURI__?.focusElement({})",
1767                    js_string(ref_id)
1768                );
1769                self.eval_page_action(&code, params.webview_label.as_deref())
1770                    .await
1771            }
1772            InteractAction::ScrollIntoView => {
1773                if !self
1774                    .state
1775                    .privacy
1776                    .is_tool_enabled("interact.scroll_into_view")
1777                {
1778                    return tool_disabled("interact.scroll_into_view");
1779                }
1780                let ref_arg = params
1781                    .ref_id
1782                    .as_ref()
1783                    .map_or_else(|| "null".to_string(), |r| js_string(r));
1784                let x = params.x.unwrap_or(0.0);
1785                let y = params.y.unwrap_or(0.0);
1786                let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
1787                self.eval_page_action(&code, params.webview_label.as_deref())
1788                    .await
1789            }
1790            InteractAction::SelectOption => {
1791                if !self.state.privacy.is_tool_enabled("interact.select_option") {
1792                    return tool_disabled("interact.select_option");
1793                }
1794                let Some(ref_id) = &params.ref_id else {
1795                    return missing_param("ref_id", "select_option");
1796                };
1797                let values_vec;
1798                let values: &[String] = match (&params.values, &params.value) {
1799                    (Some(v), _) => v,
1800                    (None, Some(v)) => {
1801                        values_vec = vec![v.clone()];
1802                        &values_vec
1803                    }
1804                    (None, None) => &[],
1805                };
1806                let values_json =
1807                    serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
1808                let code = format!(
1809                    "return window.__VICTAURI__?.selectOption({}, {})",
1810                    js_string(ref_id),
1811                    values_json
1812                );
1813                self.eval_page_action(&code, params.webview_label.as_deref())
1814                    .await
1815            }
1816        }
1817    }
1818
1819    #[tool(
1820        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].",
1821        annotations(
1822            read_only_hint = false,
1823            destructive_hint = false,
1824            idempotent_hint = false,
1825            open_world_hint = false
1826        )
1827    )]
1828    async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
1829        match params.action {
1830            InputAction::Fill => {
1831                if !self.state.privacy.is_tool_enabled("fill") {
1832                    return tool_disabled("fill");
1833                }
1834                let Some(ref_id) = &params.ref_id else {
1835                    return missing_param("ref_id", "fill");
1836                };
1837                let Some(value) = &params.value else {
1838                    return missing_param("value", "fill");
1839                };
1840                let code = format!(
1841                    "return window.__VICTAURI__?.fill({}, {})",
1842                    js_string(ref_id),
1843                    js_string(value)
1844                );
1845                self.eval_page_action(&code, params.webview_label.as_deref())
1846                    .await
1847            }
1848            InputAction::TypeText => {
1849                if !self.state.privacy.is_tool_enabled("type_text") {
1850                    return tool_disabled("type_text");
1851                }
1852                let Some(ref_id) = &params.ref_id else {
1853                    return missing_param("ref_id", "type_text");
1854                };
1855                let Some(text) = &params.text else {
1856                    return missing_param("text", "type_text");
1857                };
1858                if params.trusted.unwrap_or(false) {
1859                    // Focus the element via JS — and confirm focus landed on it, since the OS
1860                    // keystrokes go to whatever holds focus — then deliver real OS keystrokes
1861                    // (isTrusted: true) for handlers that reject synthetic events.
1862                    if let Err(refused) = self
1863                        .focus_for_trusted_input(ref_id, params.webview_label.as_deref())
1864                        .await
1865                    {
1866                        return refused;
1867                    }
1868                    let bridge = self.bridge.clone();
1869                    let label = params.webview_label.clone();
1870                    let text = text.to_string();
1871                    let native = tokio::task::spawn_blocking(move || {
1872                        bridge.native_type_text(label.as_deref(), &text)
1873                    })
1874                    .await
1875                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1876                    return match native {
1877                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1878                        Err(e) => tool_error(e),
1879                    };
1880                }
1881                let code = format!(
1882                    "return window.__VICTAURI__?.type({}, {})",
1883                    js_string(ref_id),
1884                    js_string(text)
1885                );
1886                self.eval_page_action(&code, params.webview_label.as_deref())
1887                    .await
1888            }
1889            InputAction::PressKey => {
1890                if !self.state.privacy.is_tool_enabled("input.press_key") {
1891                    return tool_disabled("input.press_key");
1892                }
1893                let Some(key) = &params.key else {
1894                    return missing_param("key", "press_key");
1895                };
1896                if params.trusted.unwrap_or(false) {
1897                    // Optionally focus a target element, then send a real OS key. A failed focus
1898                    // must stop here: the key would otherwise go to whatever holds focus.
1899                    if let Some(ref_id) = &params.ref_id
1900                        && let Err(refused) = self
1901                            .focus_for_trusted_input(ref_id, params.webview_label.as_deref())
1902                            .await
1903                    {
1904                        return refused;
1905                    }
1906                    let bridge = self.bridge.clone();
1907                    let label = params.webview_label.clone();
1908                    let key = key.to_string();
1909                    let native = tokio::task::spawn_blocking(move || {
1910                        bridge.native_key(label.as_deref(), &key)
1911                    })
1912                    .await
1913                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1914                    return match native {
1915                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1916                        Err(e) => tool_error(e),
1917                    };
1918                }
1919                let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
1920                self.eval_page_action(&code, params.webview_label.as_deref())
1921                    .await
1922            }
1923        }
1924    }
1925
1926    #[tool(
1927        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).",
1928        annotations(
1929            read_only_hint = false,
1930            destructive_hint = true,
1931            idempotent_hint = false,
1932            open_world_hint = false
1933        )
1934    )]
1935    async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
1936        match params.action {
1937            WindowAction::GetState => {
1938                let states = match self.bridge.try_get_window_states(params.label.as_deref()) {
1939                    Ok(states) => states,
1940                    Err(e) => return tool_error(ui_busy(&e)),
1941                };
1942                // A specific label that matches no window is an error, not an
1943                // empty array (which reads as "success, no state").
1944                if states.is_empty()
1945                    && let Some(label) = params.label.as_deref()
1946                {
1947                    return tool_error(format!(
1948                        "window not found: '{label}' (use window.list to see available labels)"
1949                    ));
1950                }
1951                json_result(&states)
1952            }
1953            WindowAction::List => match self.bridge.try_list_window_labels() {
1954                Ok(labels) => json_result(&labels),
1955                Err(e) => tool_error(ui_busy(&e)),
1956            },
1957            WindowAction::Introspectability => self.window_introspectability().await,
1958            WindowAction::Manage => {
1959                if !self.state.privacy.is_tool_enabled("window.manage") {
1960                    return tool_disabled("window.manage");
1961                }
1962                let Some(manage_action) = &params.manage_action else {
1963                    return missing_param("manage_action", "manage");
1964                };
1965                match self
1966                    .bridge
1967                    .manage_window(params.label.as_deref(), manage_action.as_str())
1968                {
1969                    Ok(msg) => CallToolResult::success(vec![ContentBlock::text(msg)]),
1970                    Err(e) => tool_error(e),
1971                }
1972            }
1973            WindowAction::Resize => {
1974                if !self.state.privacy.is_tool_enabled("window.resize") {
1975                    return tool_disabled("window.resize");
1976                }
1977                let Some(width) = params.width else {
1978                    return missing_param("width", "resize");
1979                };
1980                let Some(height) = params.height else {
1981                    return missing_param("height", "resize");
1982                };
1983                if width == 0 || height == 0 {
1984                    return tool_error_with_hint(
1985                        format!(
1986                            "invalid window size {width}x{height}: width and height must be > 0"
1987                        ),
1988                        RecoveryHint::CheckInput,
1989                    );
1990                }
1991                match self
1992                    .bridge
1993                    .resize_window(params.label.as_deref(), width, height)
1994                {
1995                    Ok(()) => {
1996                        let result =
1997                            serde_json::json!({"ok": true, "width": width, "height": height});
1998                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
1999                    }
2000                    Err(e) => tool_error(e),
2001                }
2002            }
2003            WindowAction::MoveTo => {
2004                if !self.state.privacy.is_tool_enabled("window.move_to") {
2005                    return tool_disabled("window.move_to");
2006                }
2007                let Some(x) = params.x else {
2008                    return missing_param("x", "move_to");
2009                };
2010                let Some(y) = params.y else {
2011                    return missing_param("y", "move_to");
2012                };
2013                match self.bridge.move_window(params.label.as_deref(), x, y) {
2014                    Ok(()) => {
2015                        let result = serde_json::json!({"ok": true, "x": x, "y": y});
2016                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2017                    }
2018                    Err(e) => tool_error(e),
2019                }
2020            }
2021            WindowAction::SetTitle => {
2022                if !self.state.privacy.is_tool_enabled("window.set_title") {
2023                    return tool_disabled("window.set_title");
2024                }
2025                let Some(title) = &params.title else {
2026                    return missing_param("title", "set_title");
2027                };
2028                match self.bridge.set_window_title(params.label.as_deref(), title) {
2029                    Ok(()) => {
2030                        let result = serde_json::json!({"ok": true, "title": title});
2031                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2032                    }
2033                    Err(e) => tool_error(e),
2034                }
2035            }
2036        }
2037    }
2038
2039    #[tool(
2040        description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
2041        annotations(
2042            read_only_hint = false,
2043            destructive_hint = true,
2044            idempotent_hint = false,
2045            open_world_hint = false
2046        )
2047    )]
2048    async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
2049        match params.action {
2050            StorageAction::Get => {
2051                let method = match params.storage_type.unwrap_or(StorageType::Local) {
2052                    StorageType::Session => "getSessionStorage",
2053                    StorageType::Local => "getLocalStorage",
2054                };
2055                let key_arg = params
2056                    .key
2057                    .as_ref()
2058                    .map(|k| js_string(k))
2059                    .unwrap_or_default();
2060                let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
2061                self.eval_bridge(&code, params.webview_label.as_deref())
2062                    .await
2063            }
2064            StorageAction::Set => {
2065                if !self.state.privacy.is_tool_enabled("set_storage") {
2066                    return tool_disabled("set_storage");
2067                }
2068                let method = match params.storage_type.unwrap_or(StorageType::Local) {
2069                    StorageType::Session => "setSessionStorage",
2070                    StorageType::Local => "setLocalStorage",
2071                };
2072                let Some(key) = &params.key else {
2073                    return missing_param("key", "set");
2074                };
2075                // Operator-protected keys (auth/role/tier/flags) can't be poisoned
2076                // via storage.set (audit #33).
2077                if !self.state.privacy.is_storage_key_allowed(key) {
2078                    return tool_error(format!(
2079                        "storage key '{key}' is protected by privacy configuration"
2080                    ));
2081                }
2082                let value = params
2083                    .value
2084                    .as_ref()
2085                    .cloned()
2086                    .unwrap_or(serde_json::Value::Null);
2087                let value_json =
2088                    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
2089                let code = format!(
2090                    "return window.__VICTAURI__?.{method}({}, {value_json})",
2091                    js_string(key)
2092                );
2093                self.eval_bridge(&code, params.webview_label.as_deref())
2094                    .await
2095            }
2096            StorageAction::Delete => {
2097                if !self.state.privacy.is_tool_enabled("delete_storage") {
2098                    return tool_disabled("delete_storage");
2099                }
2100                let method = match params.storage_type.unwrap_or(StorageType::Local) {
2101                    StorageType::Session => "deleteSessionStorage",
2102                    StorageType::Local => "deleteLocalStorage",
2103                };
2104                let Some(key) = &params.key else {
2105                    return missing_param("key", "delete");
2106                };
2107                let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
2108                self.eval_bridge(&code, params.webview_label.as_deref())
2109                    .await
2110            }
2111            StorageAction::GetCookies => {
2112                self.eval_bridge(
2113                    "return window.__VICTAURI__?.getCookies()",
2114                    params.webview_label.as_deref(),
2115                )
2116                .await
2117            }
2118        }
2119    }
2120
2121    #[tool(
2122        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.",
2123        annotations(
2124            read_only_hint = false,
2125            destructive_hint = false,
2126            idempotent_hint = false,
2127            open_world_hint = false
2128        )
2129    )]
2130    async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
2131        match params.action {
2132            NavigateAction::GoTo => {
2133                if !self.state.privacy.is_tool_enabled("navigate") {
2134                    return tool_disabled("navigate");
2135                }
2136                let Some(url) = &params.url else {
2137                    return missing_param("url", "go_to");
2138                };
2139                if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
2140                    return tool_error(e);
2141                }
2142                let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
2143                self.eval_bridge(&code, params.webview_label.as_deref())
2144                    .await
2145            }
2146            NavigateAction::GoBack => {
2147                self.eval_bridge(
2148                    "return window.__VICTAURI__?.navigateBack()",
2149                    params.webview_label.as_deref(),
2150                )
2151                .await
2152            }
2153            NavigateAction::GetHistory => {
2154                self.eval_bridge(
2155                    "return window.__VICTAURI__?.getNavigationLog()",
2156                    params.webview_label.as_deref(),
2157                )
2158                .await
2159            }
2160            NavigateAction::SetDialogResponse => {
2161                if !self.state.privacy.is_tool_enabled("set_dialog_response") {
2162                    return tool_disabled("set_dialog_response");
2163                }
2164                let Some(dialog_type) = params.dialog_type else {
2165                    return missing_param("dialog_type", "set_dialog_response");
2166                };
2167                let Some(dialog_action) = params.dialog_action else {
2168                    return missing_param("dialog_action", "set_dialog_response");
2169                };
2170                let text_arg = params
2171                    .text
2172                    .as_ref()
2173                    .map_or_else(|| "undefined".to_string(), |t| js_string(t));
2174                let code = format!(
2175                    "return {}?.setDialogAutoResponse({}, {}, {text_arg})",
2176                    crate::js_bridge::agent_ops_js(),
2177                    js_string(dialog_type.as_str()),
2178                    js_string(dialog_action.as_str())
2179                );
2180                self.eval_bridge(&code, params.webview_label.as_deref())
2181                    .await
2182            }
2183            NavigateAction::GetDialogLog => {
2184                self.eval_bridge(
2185                    "return window.__VICTAURI__?.getDialogLog()",
2186                    params.webview_label.as_deref(),
2187                )
2188                .await
2189            }
2190        }
2191    }
2192
2193    #[tool(
2194        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, or were answered by a route rule 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).",
2195        annotations(
2196            read_only_hint = false,
2197            destructive_hint = true,
2198            idempotent_hint = false,
2199            open_world_hint = false
2200        )
2201    )]
2202    async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
2203        const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
2204        if !self.state.privacy.is_tool_enabled("recording") {
2205            return tool_disabled("recording");
2206        }
2207        match params.action {
2208            RecordingAction::Start => {
2209                let session_id = params
2210                    .session_id
2211                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
2212                let floor_ms = now_ms();
2213                match self.state.recorder.start_session(session_id.clone()) {
2214                    Ok(generation) => {
2215                        self.state.drain_watermarks.reset(floor_ms, generation);
2216                        let result = serde_json::json!({
2217                            "started": true,
2218                            "session_id": session_id,
2219                        });
2220                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2221                    }
2222                    Err(e) => tool_error(e.to_string()),
2223                }
2224            }
2225            RecordingAction::Stop => {
2226                // Final flush first: the background drain reads each window about once a second, so
2227                // anything the page captured since its last tick would otherwise be lost — and under
2228                // a busy UI no drain may have run at all. Best-effort and bounded; a window that
2229                // cannot answer in time is REPORTED, never silently dropped.
2230                let unreachable = if self.state.recorder.is_recording() {
2231                    self.final_recording_flush().await
2232                } else {
2233                    Vec::new()
2234                };
2235                match self.state.recorder.stop() {
2236                    Some(session) => {
2237                        if unreachable.is_empty() {
2238                            json_result(&session)
2239                        } else {
2240                            let mut value =
2241                                serde_json::to_value(&session).unwrap_or(serde_json::Value::Null);
2242                            if let Some(obj) = value.as_object_mut() {
2243                                obj.insert(
2244                                    "final_flush_unreachable".to_string(),
2245                                    serde_json::json!(unreachable),
2246                                );
2247                            }
2248                            json_result(&value)
2249                        }
2250                    }
2251                    None => tool_error("no recording is active"),
2252                }
2253            }
2254            RecordingAction::Checkpoint => {
2255                // checkpoint_id is optional — auto-generate a short id when the
2256                // caller just wants a positional marker. The id is echoed back in
2257                // the response so it can be referenced later
2258                // (events_between_checkpoints / replay).
2259                let id = params
2260                    .checkpoint_id
2261                    .unwrap_or_else(|| format!("cp-{}", uuid::Uuid::new_v4()));
2262                let state = params.state.unwrap_or(serde_json::Value::Null);
2263                match self
2264                    .state
2265                    .recorder
2266                    .checkpoint(id.clone(), params.checkpoint_label, state)
2267                {
2268                    Ok(()) => {
2269                        let result = serde_json::json!({
2270                            "created": true,
2271                            "checkpoint_id": id,
2272                            "event_index": self.state.recorder.event_count(),
2273                        });
2274                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2275                    }
2276                    Err(e) => tool_error(e.to_string()),
2277                }
2278            }
2279            RecordingAction::ListCheckpoints => {
2280                let checkpoints = self.state.recorder.get_checkpoints();
2281                json_result(&checkpoints)
2282            }
2283            RecordingAction::GetEvents => {
2284                let events = self
2285                    .state
2286                    .recorder
2287                    .events_since(params.since_index.unwrap_or(0));
2288                json_result(&events)
2289            }
2290            RecordingAction::EventsBetween => {
2291                let Some(from) = &params.from else {
2292                    return missing_param("from", "events_between");
2293                };
2294                let Some(to) = &params.to else {
2295                    return missing_param("to", "events_between");
2296                };
2297                match self.state.recorder.events_between_checkpoints(from, to) {
2298                    Ok(events) => json_result(&events),
2299                    Err(e) => tool_error(e.to_string()),
2300                }
2301            }
2302            RecordingAction::GetReplay => {
2303                let calls = self.state.recorder.ipc_replay_sequence();
2304                json_result(&calls)
2305            }
2306            RecordingAction::Export => match self.state.recorder.export() {
2307                Some(s) => {
2308                    let json = serde_json::to_string_pretty(&s)
2309                        .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
2310                    CallToolResult::success(vec![ContentBlock::text(json)])
2311                }
2312                None => tool_error("no recording is active — start one first"),
2313            },
2314            RecordingAction::Import => {
2315                let Some(session_json) = &params.session_json else {
2316                    return missing_param("session_json", "import");
2317                };
2318                if session_json.len() > MAX_SESSION_JSON {
2319                    return tool_error("session JSON exceeds maximum size (10 MB)");
2320                }
2321                let session: victauri_core::RecordedSession =
2322                    match serde_json::from_str(session_json) {
2323                        Ok(s) => s,
2324                        Err(e) => return tool_error(format!("invalid session JSON: {e}")),
2325                    };
2326
2327                let result = serde_json::json!({
2328                    "imported": true,
2329                    "session_id": session.id,
2330                    "event_count": session.events.len(),
2331                    "checkpoint_count": session.checkpoints.len(),
2332                    "started_at": session.started_at.to_rfc3339(),
2333                    "recording_active": true,
2334                    "note": "the imported session is now the ACTIVE recording: live events are \
2335                             appended to it until you call recording action=stop",
2336                });
2337                // Import REPLACES the active recording. Doing that silently threw away an
2338                // in-progress recording, so require it to be stopped (and saved) first — checked
2339                // and replaced in one step, so a recording started concurrently is never lost.
2340                let floor_ms = now_ms();
2341                match self.state.recorder.import_if_idle(session) {
2342                    Ok(generation) => self.state.drain_watermarks.reset(floor_ms, generation),
2343                    Err(_) => {
2344                        return tool_error(
2345                            "a recording is in progress — import would discard it. Stop it first \
2346                             (recording action=stop, or export it), then import.",
2347                        );
2348                    }
2349                }
2350                CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2351            }
2352            RecordingAction::Flush => {
2353                if !self.state.recorder.is_recording() {
2354                    return tool_error("no active recording — start a recording first");
2355                }
2356                // Same routine (and the same shared per-window watermark + lock) as the
2357                // background drain, so a flush never re-records what the drain already captured
2358                // and vice versa. With no label, every live window is flushed.
2359                let labels: Vec<String> = match params.webview_label.as_deref() {
2360                    Some(l) => vec![l.to_string()],
2361                    None => match self.bridge.try_list_window_labels() {
2362                        Ok(labels) => labels,
2363                        Err(e) => return tool_error(ui_busy(&e)),
2364                    },
2365                };
2366                let mut captured = 0usize;
2367                let mut failed = Vec::new();
2368                for label in &labels {
2369                    match crate::mcp::server::drain_window_into_recording(
2370                        &self.state,
2371                        &self.bridge,
2372                        label,
2373                    )
2374                    .await
2375                    {
2376                        Some(n) => captured += n,
2377                        None => failed.push(label.clone()),
2378                    }
2379                }
2380                if !labels.is_empty() && failed.len() == labels.len() {
2381                    return tool_error(format!(
2382                        "flush failed: no window answered ({})",
2383                        failed.join(", ")
2384                    ));
2385                }
2386                json_result(&serde_json::json!({
2387                    "flushed": true,
2388                    "events_captured": captured,
2389                    "windows": labels,
2390                    "unreachable_windows": failed,
2391                }))
2392            }
2393            RecordingAction::Replay => {
2394                let calls = self.state.recorder.ipc_replay_sequence();
2395                if calls.is_empty() {
2396                    return tool_error("no IPC calls recorded — record a session first");
2397                }
2398                let mut replay_results = Vec::new();
2399                for call in &calls {
2400                    // Recordings do not capture call ARGUMENTS, so a replay re-invokes each
2401                    // command with none. Re-running a call that took arguments is guaranteed
2402                    // wrong (it fails, or worse, runs with defaults), and re-running a call
2403                    // that failed or never completed reproduces nothing — skip both, loudly.
2404                    // A call a route rule answered in the page never reached the backend:
2405                    // replaying it would turn a fake (possibly page-forged) success into a real
2406                    // invocation.
2407                    let skip_reason = match &call.result {
2408                        _ if call.mocked => Some(
2409                            "the original call was answered by a route rule in the page, not the backend".to_string(),
2410                        ),
2411                        victauri_core::IpcResult::Ok(_) if call.arg_size_bytes > 0 => Some(
2412                            "the original call had arguments, which recordings do not capture".to_string(),
2413                        ),
2414                        victauri_core::IpcResult::Ok(_) => params
2415                            .webview_label
2416                            .as_deref()
2417                            .filter(|only| *only != call.webview_label)
2418                            .map(|only| {
2419                                format!(
2420                                    "recorded in window '{}'; replay was limited to '{only}'",
2421                                    call.webview_label
2422                                )
2423                            }),
2424                        victauri_core::IpcResult::Err(_) => {
2425                            Some("the original call failed".to_string())
2426                        }
2427                        _ => Some("the original call never completed".to_string()),
2428                    };
2429                    if let Some(reason) = skip_reason {
2430                        replay_results.push(serde_json::json!({
2431                            "command": call.command,
2432                            "status": "skipped",
2433                            "reason": reason,
2434                        }));
2435                        continue;
2436                    }
2437                    // Enforce the same command allow/blocklist as invoke_command
2438                    // (audit #30/#31): a recorded/imported session must not be able to
2439                    // invoke a command an operator blocked.
2440                    if !self.state.privacy.is_invoke_allowed(&call.command)
2441                        || !self.state.privacy.is_command_allowed(&call.command)
2442                    {
2443                        replay_results.push(serde_json::json!({
2444                            "command": call.command,
2445                            "status": "blocked",
2446                            "error": "blocked by privacy configuration",
2447                        }));
2448                        continue;
2449                    }
2450                    let code = format!(
2451                        "return window.__TAURI_INTERNALS__.invoke({})",
2452                        js_string(&call.command)
2453                    );
2454                    // Replay each call in the window that made it — never a default window: a
2455                    // command recorded in a low-privilege window must not run with main's
2456                    // capabilities. A window that no longer exists fails the call (an explicit
2457                    // label never falls back to another window).
2458                    let outcome = match self
2459                        .eval_with_return(&code, Some(call.webview_label.as_str()))
2460                        .await
2461                    {
2462                        Ok(result_str) => {
2463                            let value: serde_json::Value = serde_json::from_str(&result_str)
2464                                .unwrap_or(serde_json::Value::String(result_str));
2465                            let shape = crate::introspection::JsonShape::from_value(&value);
2466                            serde_json::json!({
2467                                "command": call.command,
2468                                "webview_label": call.webview_label,
2469                                "status": "ok",
2470                                "response_type": shape.type_name(),
2471                            })
2472                        }
2473                        Err(e) => {
2474                            serde_json::json!({
2475                                "command": call.command,
2476                                "status": "error",
2477                                "error": e,
2478                            })
2479                        }
2480                    };
2481                    replay_results.push(outcome);
2482                }
2483                let count = |status: &str| {
2484                    replay_results
2485                        .iter()
2486                        .filter(|r| r.get("status").and_then(|s| s.as_str()) == Some(status))
2487                        .count()
2488                };
2489                let (passed, skipped) = (count("ok"), count("skipped"));
2490                let replayed = replay_results.len() - skipped;
2491                let result = serde_json::json!({
2492                    "replayed": replayed,
2493                    "passed": passed,
2494                    "failed": replayed - passed,
2495                    "skipped": skipped,
2496                    "note": "commands are re-invoked WITHOUT arguments (recordings do not capture \
2497                             them), each in the window that recorded it, and their side effects \
2498                             happen again; calls that had arguments, failed, never completed, or \
2499                             were answered by a route rule are skipped",
2500                    "results": replay_results,
2501                });
2502                json_result(&result)
2503            }
2504        }
2505    }
2506
2507    #[tool(
2508        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).",
2509        annotations(
2510            read_only_hint = false,
2511            destructive_hint = false,
2512            idempotent_hint = true,
2513            open_world_hint = false
2514        )
2515    )]
2516    async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
2517        match params.action {
2518            InspectAction::GetStyles => {
2519                let Some(ref_id) = &params.ref_id else {
2520                    return missing_param("ref_id", "get_styles");
2521                };
2522                let props_arg = match &params.properties {
2523                    Some(props) => {
2524                        let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
2525                        format!("[{}]", arr.join(","))
2526                    }
2527                    None => "null".to_string(),
2528                };
2529                let code = format!(
2530                    "return window.__VICTAURI__?.getStyles({}, {})",
2531                    js_string(ref_id),
2532                    props_arg
2533                );
2534                self.eval_page_action(&code, params.webview_label.as_deref())
2535                    .await
2536            }
2537            InspectAction::GetBoundingBoxes => {
2538                let Some(ref_ids) = &params.ref_ids else {
2539                    return missing_param("ref_ids", "get_bounding_boxes");
2540                };
2541                let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
2542                let code = format!(
2543                    "return window.__VICTAURI__?.getBoundingBoxes([{}])",
2544                    refs.join(",")
2545                );
2546                self.eval_page_action(&code, params.webview_label.as_deref())
2547                    .await
2548            }
2549            InspectAction::Highlight => {
2550                // highlight injects a debug overlay node into the page — a DOM
2551                // mutation — so it is gated separately and excluded from the
2552                // read-only Observe profile (red-team P1).
2553                if !self.state.privacy.is_tool_enabled("inspect.highlight") {
2554                    return tool_disabled("inspect.highlight");
2555                }
2556                let Some(ref_id) = &params.ref_id else {
2557                    return missing_param("ref_id", "highlight");
2558                };
2559                let color_arg = match &params.color {
2560                    Some(c) => match sanitize_css_color(c) {
2561                        Ok(safe) => format!("\"{safe}\""),
2562                        Err(e) => return tool_error(e),
2563                    },
2564                    None => "null".to_string(),
2565                };
2566                let label_arg = match &params.label {
2567                    Some(l) => js_string(l),
2568                    None => "null".to_string(),
2569                };
2570                let code = format!(
2571                    "return window.__VICTAURI__?.highlightElement({}, {}, {})",
2572                    js_string(ref_id),
2573                    color_arg,
2574                    label_arg
2575                );
2576                self.eval_page_action(&code, params.webview_label.as_deref())
2577                    .await
2578            }
2579            InspectAction::ClearHighlights => {
2580                if !self
2581                    .state
2582                    .privacy
2583                    .is_tool_enabled("inspect.clear_highlights")
2584                {
2585                    return tool_disabled("inspect.clear_highlights");
2586                }
2587                self.eval_page_action(
2588                    "return window.__VICTAURI__?.clearHighlights()",
2589                    params.webview_label.as_deref(),
2590                )
2591                .await
2592            }
2593            InspectAction::AuditAccessibility => {
2594                self.eval_page_action(
2595                    "return window.__VICTAURI__?.auditAccessibility()",
2596                    params.webview_label.as_deref(),
2597                )
2598                .await
2599            }
2600            InspectAction::GetPerformance => {
2601                self.eval_page_action(
2602                    "return window.__VICTAURI__?.getPerformanceMetrics()",
2603                    params.webview_label.as_deref(),
2604                )
2605                .await
2606            }
2607        }
2608    }
2609
2610    #[tool(
2611        description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
2612        annotations(
2613            read_only_hint = false,
2614            destructive_hint = false,
2615            idempotent_hint = true,
2616            open_world_hint = false
2617        )
2618    )]
2619    async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
2620        match params.action {
2621            CssAction::Inject => {
2622                if !self.state.privacy.is_tool_enabled("inject_css") {
2623                    return tool_disabled("inject_css");
2624                }
2625                let Some(css) = &params.css else {
2626                    return missing_param("css", "inject");
2627                };
2628                // Block remote @import / url(...) exfil vectors unless explicitly opted in.
2629                if let Err(e) = sanitize_injected_css(css, params.allow_remote) {
2630                    return tool_error(e);
2631                }
2632                let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
2633                self.eval_bridge(&code, params.webview_label.as_deref())
2634                    .await
2635            }
2636            CssAction::Remove => {
2637                if !self.state.privacy.is_tool_enabled("css.remove") {
2638                    return tool_disabled("css.remove");
2639                }
2640                self.eval_bridge(
2641                    "return window.__VICTAURI__?.removeInjectedCss()",
2642                    params.webview_label.as_deref(),
2643                )
2644                .await
2645            }
2646        }
2647    }
2648
2649    #[tool(
2650        description = "Network request interception (Playwright route() equivalent, no CDP). \
2651            Matches webview fetch/XHR by URL and blocks, mocks, or delays them. \
2652            Actions:\n\
2653            - `add`: add a rule. `pattern` (+ optional `match_type`: substring/glob/regex/exact, \
2654              and `method`) selects requests; `behavior` is `block` (abort), `fulfill` (return a \
2655              mock `status`/`headers`/`body`/`content_type`), or `delay` (proceed after `delay_ms`). \
2656              `times` limits how often it fires. Rules are page-scoped (cleared on reload).\n\
2657            - `list`: list active rules.\n\
2658            - `clear` (by `id`) / `clear_all`: remove rules.\n\
2659            - `matches`: log of intercepted requests.\n\
2660            Note: fetch supports all behaviors; XHR supports block/delay (fulfill is fetch-only). \
2661            Top-level navigation, sub-resource (img/css), and WebSocket traffic are not intercepted. \
2662            Tauri IPC (ipc.localhost) is OBSERVE-ONLY: such calls appear in `matches`, but block/\
2663            fulfill/delay do NOT take effect on them — Tauri serves IPC below the JS fetch layer, so \
2664            it cannot be controlled cross-platform without CDP. There is no IPC-control tool; the \
2665            `fault` tool only affects commands you drive via `invoke_command`, not real user IPC.",
2666        annotations(
2667            read_only_hint = false,
2668            destructive_hint = false,
2669            idempotent_hint = false,
2670            open_world_hint = false
2671        )
2672    )]
2673    async fn route(&self, Parameters(params): Parameters<RouteParams>) -> CallToolResult {
2674        match params.action {
2675            RouteAction::Add => {
2676                if !self.state.privacy.is_tool_enabled("route.add") {
2677                    return tool_disabled("route.add");
2678                }
2679                let Some(pattern) = &params.pattern else {
2680                    return missing_param("pattern", "add");
2681                };
2682                let behavior = params.behavior.unwrap_or(RouteBehavior::Fulfill);
2683                let match_type = params.match_type.unwrap_or(RouteMatchType::Substring);
2684                let mut rule = serde_json::json!({
2685                    "pattern": pattern,
2686                    "match_type": match_type.as_str(),
2687                    "action": behavior.as_str(),
2688                });
2689                if let Some(m) = &params.method {
2690                    rule["method"] = serde_json::json!(m);
2691                }
2692                if let Some(s) = params.status {
2693                    rule["status"] = serde_json::json!(s);
2694                }
2695                if let Some(st) = &params.status_text {
2696                    rule["status_text"] = serde_json::json!(st);
2697                }
2698                if let Some(h) = &params.headers {
2699                    rule["headers"] = h.clone();
2700                }
2701                if let Some(b) = &params.body {
2702                    // A JSON string body is passed through as-is; structured JSON
2703                    // is stringified so the bridge sends valid JSON text.
2704                    rule["body"] = match b {
2705                        serde_json::Value::String(s) => serde_json::json!(s),
2706                        other => serde_json::json!(other.to_string()),
2707                    };
2708                }
2709                if let Some(ct) = &params.content_type {
2710                    rule["content_type"] = serde_json::json!(ct);
2711                }
2712                if let Some(d) = params.delay_ms {
2713                    // A delayed request is held this long in the page; cap it like a `fault`
2714                    // delay rather than accept any u64.
2715                    if d > MAX_FAULT_DELAY_MS {
2716                        return tool_error_with_hint(
2717                            format!("delay_ms {d} exceeds the maximum of {MAX_FAULT_DELAY_MS} ms"),
2718                            RecoveryHint::CheckInput,
2719                        );
2720                    }
2721                    rule["delay_ms"] = serde_json::json!(d);
2722                }
2723                if let Some(t) = params.times {
2724                    rule["times"] = serde_json::json!(t);
2725                }
2726                let code = format!(
2727                    "return window.__VICTAURI__?.addRoute({})",
2728                    js_string(&rule.to_string())
2729                );
2730                self.eval_page_action(&code, params.webview_label.as_deref())
2731                    .await
2732            }
2733            RouteAction::List => {
2734                self.eval_bridge(
2735                    "return window.__VICTAURI__?.getRouteRules()",
2736                    params.webview_label.as_deref(),
2737                )
2738                .await
2739            }
2740            RouteAction::Clear => {
2741                let Some(id) = params.id else {
2742                    return missing_param("id", "clear");
2743                };
2744                let code = format!(
2745                    "return {}?.clearRoute({id})",
2746                    crate::js_bridge::agent_ops_js()
2747                );
2748                self.eval_bridge(&code, params.webview_label.as_deref())
2749                    .await
2750            }
2751            RouteAction::ClearAll => {
2752                let code = format!("return {}?.clearRoutes()", crate::js_bridge::agent_ops_js());
2753                self.eval_bridge(&code, params.webview_label.as_deref())
2754                    .await
2755            }
2756            RouteAction::Matches => {
2757                let limit = params.limit.unwrap_or(100);
2758                // A maximum of 0 entries is none — not "all" (the bridge's falsy limit).
2759                if limit == 0 {
2760                    return CallToolResult::success(vec![ContentBlock::text("[]")]);
2761                }
2762                let code = format!("return window.__VICTAURI__?.getRouteMatches({limit})");
2763                self.eval_bridge(&code, params.webview_label.as_deref())
2764                    .await
2765            }
2766        }
2767    }
2768
2769    #[tool(
2770        description = "Screencast / visual trace (no CDP). Captures the window at a fixed interval \
2771            into a ring buffer, forming a visual timeline that pairs with `recording` (events) and \
2772            `logs` (network/console). Actions:\n\
2773            - `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set \
2774              `with_events=true` to also start the event recorder. Hidden windows are skipped \
2775              (no frame), the buffer is capped at 256 MB, and an abandoned trace auto-stops \
2776              after 30 minutes.\n\
2777            - `stop`: stop and return a summary (frame count, duration, timestamps); also stops \
2778              the recording that `with_events` started (never one started separately).\n\
2779            - `status`: active flag + buffered frame count.\n\
2780            - `frames`: return captured frames as base64 PNGs, newest first up to a 25 MB \
2781              response (`limit` caps how many).",
2782        annotations(
2783            read_only_hint = false,
2784            destructive_hint = false,
2785            idempotent_hint = false,
2786            open_world_hint = false
2787        )
2788    )]
2789    async fn trace(&self, Parameters(params): Parameters<TraceParams>) -> CallToolResult {
2790        if !self.state.privacy.is_tool_enabled("trace")
2791            || !self.state.privacy.is_tool_enabled("screenshot")
2792        {
2793            return tool_disabled("trace");
2794        }
2795        match params.action {
2796            TraceAction::Start => {
2797                let interval = params.interval_ms.unwrap_or(500);
2798                let max_frames = params.max_frames.unwrap_or(60);
2799                let label = params.webview_label.clone();
2800                // A trace started over a running one supersedes it: end the recording the
2801                // previous trace started (if it is still the active one), or it would be left
2802                // running with no owner (and the per-second drain loop with it).
2803                let (generation, superseded) =
2804                    self.state
2805                        .screencast
2806                        .start(interval, max_frames, label.clone());
2807                if let Some(prev) = superseded {
2808                    let _ = self.state.recorder.stop_if_generation(prev);
2809                }
2810
2811                let mut events_started = false;
2812                if params.with_events.unwrap_or(false) {
2813                    let session_id = uuid::Uuid::new_v4().to_string();
2814                    let floor_ms = now_ms();
2815                    if let Ok(recording) = self.state.recorder.start_session(session_id) {
2816                        self.state.drain_watermarks.reset(floor_ms, recording);
2817                        // Only a recording THIS trace started is stopped with it — by recorder
2818                        // generation, so a recording started later (even under the same session
2819                        // id) is never stopped by us. If this trace was already stopped or
2820                        // superseded meanwhile, nothing would ever stop the recording: do it now.
2821                        if self
2822                            .state
2823                            .screencast
2824                            .set_owned_recording(generation, recording)
2825                        {
2826                            events_started = true;
2827                        } else {
2828                            let _ = self.state.recorder.stop_if_generation(recording);
2829                        }
2830                    }
2831                }
2832
2833                // Background capture task: snapshot the window each interval until the
2834                // screencast is stopped, superseded by a newer start, or hits the max duration.
2835                // The guard ends the trace (and its recording) however the task exits — the
2836                // max-duration break, or a panic.
2837                let handler = self.clone();
2838                let screencast = self.state.screencast.clone();
2839                let guard = crate::screencast::CaptureTaskGuard {
2840                    screencast: Arc::clone(&screencast),
2841                    recorder: self.state.recorder.clone(),
2842                    generation,
2843                };
2844                tokio::spawn(async move {
2845                    let _guard = guard;
2846                    let t0 = std::time::Instant::now();
2847                    // An abandoned trace must not capture (and burn CPU) forever: past the max
2848                    // duration the loop ends and the guard stops it (only if no newer trace has
2849                    // started in the meantime).
2850                    while screencast.is_current(generation) && t0.elapsed() < MAX_TRACE_DURATION {
2851                        // Same visible-target rule as `screenshot`: a hidden window yields
2852                        // stale or another window's pixels, so skip the frame instead.
2853                        if let Ok(target) = handler.resolve_visible_capture_target(label.as_deref())
2854                            && let Ok(handle) = handler.bridge.get_native_handle(Some(&target))
2855                            && let Ok(png) = crate::screenshot::capture_window(handle).await
2856                        {
2857                            use base64::Engine;
2858                            let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2859                            #[allow(clippy::cast_possible_truncation)]
2860                            screencast.push_frame_if_current(
2861                                generation,
2862                                t0.elapsed().as_millis() as u64,
2863                                b64,
2864                            );
2865                        }
2866                        tokio::time::sleep(std::time::Duration::from_millis(
2867                            screencast.interval_ms(),
2868                        ))
2869                        .await;
2870                    }
2871                });
2872
2873                json_result(&serde_json::json!({
2874                    "started": true,
2875                    "interval_ms": self.state.screencast.interval_ms(),
2876                    "max_frames": max_frames.clamp(1, 600),
2877                    "with_events": events_started,
2878                }))
2879            }
2880            TraceAction::Stop => {
2881                let (frame_count, owned) = self.state.screencast.stop();
2882                let timestamps = self.state.screencast.frame_timestamps();
2883                let duration_ms = timestamps.last().copied().unwrap_or(0);
2884                // Stop the recording `with_events` started, so the recorder (and the
2885                // per-second drain loop it enables) does not outlive the trace. The session
2886                // stays readable via recording get_events/export (last stopped session).
2887                let event_count = match owned {
2888                    Some(owned) => self
2889                        .state
2890                        .recorder
2891                        .stop_if_generation(owned)
2892                        .map_or(0, |session| session.events.len()),
2893                    None => self.state.recorder.event_count(),
2894                };
2895                json_result(&serde_json::json!({
2896                    "stopped": true,
2897                    "frame_count": frame_count,
2898                    "duration_ms": duration_ms,
2899                    "frame_timestamps_ms": timestamps,
2900                    "recorded_event_count": event_count,
2901                    "hint": "use action=frames to retrieve PNGs; pair with recording/get_events and logs for a full bundle",
2902                }))
2903            }
2904            TraceAction::Status => json_result(&serde_json::json!({
2905                "active": self.state.screencast.is_active(),
2906                "frame_count": self.state.screencast.frame_count(),
2907                "interval_ms": self.state.screencast.interval_ms(),
2908            })),
2909            TraceAction::Frames => {
2910                let limit = params.limit.unwrap_or(0);
2911                let frames = self.state.screencast.frames(limit);
2912                if frames.is_empty() {
2913                    return json_result(&serde_json::json!({ "frames": 0 }));
2914                }
2915                // Bound the response: keep the NEWEST frames that fit the byte budget.
2916                let available = frames.len();
2917                let mut budget = MAX_TRACE_FRAMES_RESPONSE_BYTES;
2918                let mut kept: Vec<ContentBlock> = Vec::new();
2919                for f in frames.into_iter().rev() {
2920                    if f.data_b64.len() > budget && !kept.is_empty() {
2921                        break;
2922                    }
2923                    budget = budget.saturating_sub(f.data_b64.len());
2924                    kept.push(ContentBlock::image(f.data_b64, "image/png"));
2925                }
2926                kept.reverse();
2927                if kept.len() < available {
2928                    kept.insert(
2929                        0,
2930                        ContentBlock::text(format!(
2931                            "returned the newest {} of {available} frames (response capped at \
2932                             {} MB); pass a smaller `limit` to page",
2933                            kept.len(),
2934                            MAX_TRACE_FRAMES_RESPONSE_BYTES / (1024 * 1024)
2935                        )),
2936                    );
2937                }
2938                CallToolResult::success(kept)
2939            }
2940        }
2941    }
2942
2943    #[tool(
2944        description = "Animation introspection (no CDP). Reads the Web Animations API to reveal what \
2945            the webview's animation engine is actually running — duration, delay, easing, iterations, \
2946            keyframes, current progress, and the animating element. Standard DOM, so it works \
2947            identically on WebView2/WKWebView/WebKitGTK. Actions:\n\
2948            - `list`: return all running CSS animations/transitions (optionally scoped by `selector`), \
2949              each with declared `timing`, `computed` progress, `keyframes`, and `target`.\n\
2950            - `scrub`: deterministically pause the target's animation and seek it to `points` \
2951              evenly-spaced steps (default 20), returning the exact geometry curve (rect + transform \
2952              + opacity per step). With `capture=true`, also returns a single contact-sheet filmstrip \
2953              PNG (one image of the whole arc) plus a `manifest` mapping each cell to its progress/time. \
2954              Frozen frames are jank-free, so this beats real-time capture for fast sweeps. CSS-driven \
2955              animations only (JS/rAF animations are not seekable — use `list`/`sample`).\n\
2956            - `sample`: real-time motion recorder. `record=true` arms a requestAnimationFrame watcher \
2957              on `selector` (or the first animating element); then trigger the animation; then call \
2958              with `record=false` to read the measured per-frame curve plus jank stats (dropped frames, \
2959              max frame gap) and declared-vs-measured duration. Works for ANY animation including \
2960              JS/rAF-driven ones. `clear=true` resets recorded sessions.\n\
2961            NOTE: an animation only appears while it is running or pending — trigger it (e.g. show the \
2962            notification) just before calling `list`/`scrub`, or arm `sample` before triggering.",
2963        annotations(
2964            read_only_hint = false,
2965            destructive_hint = false,
2966            idempotent_hint = false,
2967            open_world_hint = false
2968        )
2969    )]
2970    async fn animation(&self, Parameters(params): Parameters<AnimationParams>) -> CallToolResult {
2971        if !self.state.privacy.is_tool_enabled("animation") {
2972            return tool_disabled("animation");
2973        }
2974        match params.action {
2975            AnimationAction::List => {
2976                let sel = params
2977                    .selector
2978                    .as_deref()
2979                    .map_or_else(|| "null".to_string(), js_string);
2980                let code = format!(
2981                    "return window.__VICTAURI__ && window.__VICTAURI__.listAnimations({sel})"
2982                );
2983                match self
2984                    .eval_with_return(&code, params.webview_label.as_deref())
2985                    .await
2986                {
2987                    Ok(result_str) => {
2988                        match serde_json::from_str::<serde_json::Value>(&result_str) {
2989                            Ok(v) => json_result(&v),
2990                            Err(_) => CallToolResult::success(vec![ContentBlock::text(result_str)]),
2991                        }
2992                    }
2993                    Err(e) => tool_error(format!("animation list failed: {e}")),
2994                }
2995            }
2996            AnimationAction::Scrub => {
2997                // `capture=true` takes native window screenshots (a filmstrip), so it needs the
2998                // `screenshot` tool as well — like `trace` — or an operator who disabled
2999                // screenshots would still get pixels through here. Refused before anything runs.
3000                if params.capture.unwrap_or(false)
3001                    && !self.state.privacy.is_tool_enabled("screenshot")
3002                {
3003                    return tool_error_with_hint(
3004                        "animation scrub with capture=true takes native window screenshots, but \
3005                         tool 'screenshot' is disabled by privacy configuration — call scrub \
3006                         without `capture` to get the geometry curve only",
3007                        RecoveryHint::ReportToUser,
3008                    );
3009                }
3010                self.animation_scrub(params).await
3011            }
3012            AnimationAction::Sample => {
3013                let label = params.webview_label.as_deref();
3014                let sel = params
3015                    .selector
3016                    .as_deref()
3017                    .map_or_else(|| "null".to_string(), js_string);
3018                let code = if params.record.unwrap_or(false) {
3019                    crate::js_bridge::agent_op_call_js(&format!("installSweepRecorder({sel})"))
3020                } else {
3021                    let clear = params.clear.unwrap_or(false);
3022                    crate::js_bridge::agent_op_call_js(&format!("readSweep({clear})"))
3023                };
3024                match self.eval_with_return(&code, label).await {
3025                    Ok(result_str) => {
3026                        match serde_json::from_str::<serde_json::Value>(&result_str) {
3027                            Ok(v) => json_result(&v),
3028                            Err(_) => CallToolResult::success(vec![ContentBlock::text(result_str)]),
3029                        }
3030                    }
3031                    Err(e) => tool_error(format!("animation sample failed: {e}")),
3032                }
3033            }
3034        }
3035    }
3036
3037    /// Deterministic pause-seek-capture loop for `animation scrub`. Split out to
3038    /// keep the `#[tool]` method readable.
3039    async fn animation_scrub(&self, params: AnimationParams) -> CallToolResult {
3040        let label = params.webview_label.as_deref();
3041        let sel = params
3042            .selector
3043            .as_deref()
3044            .map_or_else(|| "null".to_string(), js_string);
3045
3046        // 1. Prepare: pause the target's animations, learn the timeline length.
3047        let prep_code = crate::js_bridge::agent_op_call_js(&format!("scrubPrepare({sel})"));
3048        let prep_v = match self.eval_with_return(&prep_code, label).await {
3049            Ok(s) => {
3050                serde_json::from_str::<serde_json::Value>(&s).unwrap_or(serde_json::Value::Null)
3051            }
3052            Err(e) => return tool_error(format!("scrub prepare failed: {e}")),
3053        };
3054        if prep_v.get("prepared").and_then(serde_json::Value::as_bool) != Some(true) {
3055            // Surface the helpful error/info object (no target, JS-driven, etc.).
3056            return json_result(&prep_v);
3057        }
3058
3059        let points = params.points.unwrap_or(20).clamp(2, 120);
3060        let capture = params.capture.unwrap_or(false);
3061        let cols_for = |n: usize| {
3062            params
3063                .cols
3064                .unwrap_or_else(|| crate::filmstrip::default_cols(n))
3065        };
3066        let resume = params.restore.unwrap_or(true);
3067        let restore_code = crate::js_bridge::agent_op_call_js(&format!("scrubRestore({resume})"));
3068        let mut curve: Vec<serde_json::Value> = Vec::with_capacity(points);
3069        let mut frames: Vec<crate::filmstrip::Frame> = Vec::new();
3070        let mut manifest: Vec<serde_json::Value> = Vec::new();
3071        // The first reason a frame could not be captured (reported, never swallowed).
3072        let mut capture_error: Option<String> = None;
3073
3074        // 2. Seek to each evenly-spaced point; capture the frozen frame if asked.
3075        for i in 0..points {
3076            #[allow(clippy::cast_precision_loss)]
3077            let progress = i as f64 / (points - 1) as f64;
3078            let seek_code = crate::js_bridge::agent_op_call_js(&format!("scrubSeek({progress})"));
3079            match self.eval_with_return(&seek_code, label).await {
3080                Ok(s) => {
3081                    let v = serde_json::from_str::<serde_json::Value>(&s)
3082                        .unwrap_or(serde_json::Value::Null);
3083                    if capture {
3084                        match self.capture_scrub_frame(label).await {
3085                            Ok(frame) => {
3086                                // Before holding more frames: would the finished sheet be
3087                                // composable at all? (Raw RGBA frames accumulate until then.)
3088                                if frames.is_empty()
3089                                    && let Err(e) = crate::filmstrip::check_sheet(
3090                                        frame.w,
3091                                        frame.h,
3092                                        points,
3093                                        cols_for(points),
3094                                        FILMSTRIP_GAP,
3095                                    )
3096                                {
3097                                    let _ = self.eval_with_return(&restore_code, label).await;
3098                                    let fitting = (2..points).rev().find(|&n| {
3099                                        crate::filmstrip::check_sheet(
3100                                            frame.w,
3101                                            frame.h,
3102                                            n,
3103                                            cols_for(n),
3104                                            FILMSTRIP_GAP,
3105                                        )
3106                                        .is_ok()
3107                                    });
3108                                    let advice = fitting.map_or_else(
3109                                        || "call scrub without `capture` for the geometry curve                                             (or shrink the window)"
3110                                            .to_string(),
3111                                        |n| format!("use `points` <= {n} (or more `cols`)"),
3112                                    );
3113                                    return tool_error(format!(
3114                                        "animation scrub capture refused before capturing: {e};                                          {advice}"
3115                                    ));
3116                                }
3117                                manifest.push(serde_json::json!({
3118                                    "cell": frames.len(),
3119                                    "progress": progress,
3120                                    "t": v.get("t").cloned().unwrap_or(serde_json::Value::Null),
3121                                }));
3122                                frames.push(frame);
3123                            }
3124                            Err(e) => {
3125                                capture_error.get_or_insert(e);
3126                            }
3127                        }
3128                    }
3129                    curve.push(v);
3130                }
3131                Err(e) => curve.push(serde_json::json!({ "progress": progress, "error": e })),
3132            }
3133        }
3134
3135        // 3. Restore (resume) or leave paused.
3136        let _ = self.eval_with_return(&restore_code, label).await;
3137
3138        let mut meta = serde_json::json!({
3139            "scrubbed": true,
3140            "points": points,
3141            "duration_ms": prep_v.get("duration").cloned().unwrap_or(serde_json::Value::Null),
3142            "anim_count": prep_v.get("anim_count").cloned().unwrap_or(serde_json::Value::Null),
3143            "target": prep_v.get("target").cloned().unwrap_or(serde_json::Value::Null),
3144            // True only when a filmstrip is returned with this result.
3145            "captured": false,
3146            "curve": curve,
3147        });
3148        if let Some(e) = &capture_error {
3149            meta["capture_error"] = serde_json::json!(e);
3150        }
3151
3152        // 4. Compose the filmstrip if we captured frames.
3153        if capture && !frames.is_empty() {
3154            let cols = cols_for(frames.len());
3155            let (rgba, w, h) =
3156                match crate::filmstrip::compose(&frames, cols, FILMSTRIP_GAP, [20, 20, 20, 255]) {
3157                    Ok(sheet) => sheet,
3158                    Err(e) => return tool_error(format!("filmstrip compose failed: {e}")),
3159                };
3160            drop(frames);
3161            match crate::screenshot::encode_png(w, h, &rgba) {
3162                Ok(png) => {
3163                    use base64::Engine;
3164                    let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
3165                    meta["captured"] = serde_json::json!(true);
3166                    meta["filmstrip"] = serde_json::json!({
3167                        "cols": cols,
3168                        "frame_count": manifest.len(),
3169                        "width": w,
3170                        "height": h,
3171                        "manifest": manifest,
3172                    });
3173                    return CallToolResult::success(vec![
3174                        ContentBlock::image(b64, "image/png"),
3175                        ContentBlock::text(meta.to_string()),
3176                    ]);
3177                }
3178                Err(e) => return tool_error(format!("filmstrip encode failed: {e}")),
3179            }
3180        }
3181
3182        json_result(&meta)
3183    }
3184
3185    /// One native capture of the (frozen) scrub target window, as a filmstrip frame.
3186    async fn capture_scrub_frame(
3187        &self,
3188        label: Option<&str>,
3189    ) -> Result<crate::filmstrip::Frame, String> {
3190        let handle = self
3191            .bridge
3192            .get_native_handle(label)
3193            .map_err(|e| format!("no native window handle: {e}"))?;
3194        let (rgba, w, h) = crate::screenshot::capture_window_raw(handle)
3195            .await
3196            .map_err(|e| format!("window capture failed: {e}"))?;
3197        crate::filmstrip::Frame::new(rgba, w, h)
3198            .ok_or_else(|| format!("window capture returned a malformed {w}x{h} frame"))
3199    }
3200
3201    #[tool(
3202        description = "Application logs and monitoring. Actions: console (captured console.log/warn/error), network (intercepted fetch/XHR), ipc (IPC call log — set wait_for_capture=true to await response capture up to 500ms), navigation (URL change history), dialogs (alert/confirm/prompt events), events (combined event stream), slow_ipc (find slow IPC calls), clear (DELETES the captured IPC + network logs — use for per-test isolation).",
3203        annotations(
3204            read_only_hint = false,
3205            destructive_hint = true,
3206            idempotent_hint = true,
3207            open_world_hint = false
3208        )
3209    )]
3210    async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
3211        // `limit` is the maximum number of entries to return, so 0 returns none (R5-JS5). The
3212        // page is not asked at all: `.slice(-0)` and the bridge's "falsy limit = everything"
3213        // used to turn it into EVERY entry, bodies included.
3214        if params.limit == Some(0)
3215            && matches!(
3216                params.action,
3217                LogsAction::Console
3218                    | LogsAction::Network
3219                    | LogsAction::Ipc
3220                    | LogsAction::Navigation
3221                    | LogsAction::Dialogs
3222                    | LogsAction::Events
3223            )
3224        {
3225            return CallToolResult::success(vec![ContentBlock::text("[]")]);
3226        }
3227        match params.action {
3228            LogsAction::Console => {
3229                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
3230                let base = if since_arg.is_empty() {
3231                    "window.__VICTAURI__?.getConsoleLogs()".to_string()
3232                } else {
3233                    format!("window.__VICTAURI__?.getConsoleLogs({since_arg})")
3234                };
3235                let code = if let Some(limit) = params.limit {
3236                    format!("return ({base} || []).slice(-{limit})")
3237                } else {
3238                    format!("return {base}")
3239                };
3240                self.eval_bridge(&code, params.webview_label.as_deref())
3241                    .await
3242            }
3243            LogsAction::Network => {
3244                let filter_arg = params
3245                    .filter
3246                    .as_ref()
3247                    .map_or_else(|| "null".to_string(), |f| js_string(f));
3248                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
3249                let source = format!("window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit})");
3250                let code = trimmed_log_js(&source, limit);
3251                self.eval_bridge(&code, params.webview_label.as_deref())
3252                    .await
3253            }
3254            LogsAction::Ipc => {
3255                let wait = params.wait_for_capture.unwrap_or(false);
3256                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
3257                if wait {
3258                    let inner =
3259                        trimmed_log_js(&format!("window.__VICTAURI__.getIpcLog({limit})"), limit);
3260                    let code = format!(
3261                        r"return (async function() {{
3262                            await window.__VICTAURI__.waitForIpcComplete(500);
3263                            return (function() {{ {inner} }})();
3264                        }})()"
3265                    );
3266                    let timeout = std::time::Duration::from_millis(5000);
3267                    match self
3268                        .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
3269                        .await
3270                    {
3271                        Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
3272                        Err(e) => tool_error(e),
3273                    }
3274                } else {
3275                    let code =
3276                        trimmed_log_js(&format!("window.__VICTAURI__?.getIpcLog({limit})"), limit);
3277                    self.eval_bridge(&code, params.webview_label.as_deref())
3278                        .await
3279                }
3280            }
3281            LogsAction::Navigation => {
3282                let code = if let Some(limit) = params.limit {
3283                    format!(
3284                        "return (window.__VICTAURI__?.getNavigationLog() || []).slice(-{limit})"
3285                    )
3286                } else {
3287                    "return window.__VICTAURI__?.getNavigationLog()".to_string()
3288                };
3289                self.eval_bridge(&code, params.webview_label.as_deref())
3290                    .await
3291            }
3292            LogsAction::Dialogs => {
3293                let code = if let Some(limit) = params.limit {
3294                    format!("return (window.__VICTAURI__?.getDialogLog() || []).slice(-{limit})")
3295                } else {
3296                    "return window.__VICTAURI__?.getDialogLog()".to_string()
3297                };
3298                self.eval_bridge(&code, params.webview_label.as_deref())
3299                    .await
3300            }
3301            LogsAction::Events => {
3302                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
3303                let base = if since_arg.is_empty() {
3304                    "window.__VICTAURI__?.getEventStream()".to_string()
3305                } else {
3306                    format!("window.__VICTAURI__?.getEventStream({since_arg})")
3307                };
3308                let code = if let Some(limit) = params.limit {
3309                    format!("return ({base} || []).slice(-{limit})")
3310                } else {
3311                    format!("return {base}")
3312                };
3313                self.eval_bridge(&code, params.webview_label.as_deref())
3314                    .await
3315            }
3316            LogsAction::SlowIpc => {
3317                let Some(threshold) = params.threshold_ms else {
3318                    return missing_param("threshold_ms", "slow_ipc");
3319                };
3320                let limit = params.limit.unwrap_or(20);
3321                let code = slow_ipc_js(threshold, limit);
3322                self.eval_bridge(&code, params.webview_label.as_deref())
3323                    .await
3324            }
3325            LogsAction::Clear => {
3326                // Clearing the IPC/network logs erases captured evidence — a
3327                // mutation of observable state — so it is gated separately and
3328                // excluded from the read-only Observe profile (red-team P1).
3329                if !self.state.privacy.is_tool_enabled("logs.clear") {
3330                    return tool_disabled("logs.clear");
3331                }
3332                let code = format!(
3333                    "return (function(){{ var b = {}; if (!b) return {{ ok:false, error:'bridge unavailable' }}; b.clearIpcLog(); b.clearNetworkLog(); return {{ ok:true, cleared:['ipc','network'] }}; }})()",
3334                    crate::js_bridge::agent_ops_js()
3335                );
3336                self.eval_bridge(&code, params.webview_label.as_deref())
3337                    .await
3338            }
3339        }
3340    }
3341
3342    // ── Backend Introspection ────────────────────────────────────────────────
3343
3344    #[tool(
3345        description = "Deep backend introspection — command profiling, IPC contract testing, \
3346            coverage, startup timing, capability auditing, database diagnostics, process \
3347            enumeration, and event bus monitoring. \
3348            These features exploit Victauri's position inside the Rust process.\n\n\
3349            Actions:\n\
3350            - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n\
3351            - `coverage`: Which registered commands have been called during this session.\n\
3352            - `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\
3353            - `contract_record`: Record a command's response shape as a baseline (requires `command`).\n\
3354            - `contract_check`: Check all recorded contracts for schema drift.\n\
3355            - `contract_list`: List all recorded contract baselines.\n\
3356            - `contract_clear`: Clear all recorded contract baselines.\n\
3357            - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
3358            - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
3359            - `db_health`: Read-only SQLite diagnostics: journal mode, page stats, freelist, \
3360              per-table row counts, and SQLite `quick_check` — each phase budgeted; a phase that \
3361              runs out is reported (`row_counts_complete`, `integrity_check: \"not completed…\"`) \
3362              instead of failing.\n\
3363            - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
3364            - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
3365            - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
3366            - `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\
3367            - `event_bus_clear`: Clear the event bus capture buffer.",
3368        annotations(
3369            read_only_hint = false,
3370            destructive_hint = true,
3371            idempotent_hint = false,
3372            open_world_hint = false
3373        )
3374    )]
3375    async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
3376        if !self.state.privacy.is_tool_enabled("introspect") {
3377            return tool_disabled("introspect");
3378        }
3379
3380        match params.action {
3381            IntrospectAction::CommandTimings => {
3382                let mut stats = self.state.command_timings.all_stats();
3383                let driven_count = stats.len();
3384                if let Some(threshold) = params.slow_threshold_ms {
3385                    stats.retain(|s| s.avg_ms >= threshold);
3386                }
3387
3388                // Real frontend traffic: derive per-command latency from the live IPC
3389                // log so the profiler is not blind to commands the app itself drives.
3390                // `command_timings` (above) only records Victauri-driven invoke_command
3391                // calls — on a running app that counter is typically 0 while the app
3392                // makes hundreds of real calls. The IPC log captures those with
3393                // duration; the name+duration projection stays under the eval cap.
3394                let code = ipc_timing_projection_js(None);
3395                let mut ipc_traffic = match self
3396                    .eval_with_return(&code, params.webview_label.as_deref())
3397                    .await
3398                {
3399                    Ok(json_str) => page_json::parse_page_json::<Vec<serde_json::Value>>(&json_str)
3400                        .map(|entries| ipc_timing_stats(&entries))
3401                        .unwrap_or_default(),
3402                    Err(_) => Vec::new(),
3403                };
3404                if let Some(threshold) = params.slow_threshold_ms {
3405                    ipc_traffic.retain(|s| {
3406                        s.get("avg_ms")
3407                            .and_then(serde_json::Value::as_f64)
3408                            .is_some_and(|a| a >= threshold)
3409                    });
3410                }
3411
3412                let result = serde_json::json!({
3413                    "commands": stats,
3414                    "total_commands_profiled": driven_count,
3415                    // The store stops tracking NEW command names at its cap; say so rather
3416                    // than let a missing command look like one that never ran.
3417                    "saturated": driven_count >= COMMAND_TIMINGS_CAP,
3418                    "ipc_traffic": ipc_traffic,
3419                    "ipc_commands_observed": ipc_traffic.len(),
3420                    "slow_threshold_ms": params.slow_threshold_ms,
3421                    "note": "`commands` profiles ONLY commands you drove through Victauri's \
3422                             invoke_command tool (often empty on a live app). `ipc_traffic` \
3423                             profiles the app's REAL frontend IPC, derived from the live IPC \
3424                             log (per-command call_count + min/max/avg/p95 latency) — that is \
3425                             the one reflecting actual usage.",
3426                });
3427                json_result(&result)
3428            }
3429            IntrospectAction::Coverage => {
3430                let registered: Vec<String> = self
3431                    .state
3432                    .registry
3433                    .list()
3434                    .iter()
3435                    .map(|c| c.name.clone())
3436                    .collect();
3437
3438                // Project to command NAMES ONLY. The previous full `getIpcLog()` carried
3439                // request/response bodies and blew the eval result cap on busy apps,
3440                // silently returning an empty set and reporting "0 invoked" despite live
3441                // traffic. This is the same name projection ghost detection uses.
3442                let code = ghost_ipc_projection_js(None);
3443                let (invoked, ipc_calls_observed): (std::collections::HashSet<String>, usize) =
3444                    match self
3445                        .eval_with_return(&code, params.webview_label.as_deref())
3446                        .await
3447                    {
3448                        Ok(json_str) => {
3449                            match page_json::parse_page_json::<Vec<String>>(&json_str) {
3450                                Ok(names) => {
3451                                    let count = names.len();
3452                                    (names.into_iter().collect(), count)
3453                                }
3454                                Err(_) => (std::collections::HashSet::new(), 0),
3455                            }
3456                        }
3457                        Err(_) => (std::collections::HashSet::new(), 0),
3458                    };
3459
3460                let uncovered: Vec<&String> = registered
3461                    .iter()
3462                    .filter(|cmd| !invoked.contains(cmd.as_str()))
3463                    .collect();
3464
3465                let coverage_pct = if registered.is_empty() {
3466                    100.0
3467                } else {
3468                    let covered = registered.len() - uncovered.len();
3469                    (covered as f64 / registered.len() as f64) * 100.0
3470                };
3471
3472                let note = if registered.is_empty() {
3473                    Some(
3474                        "The introspection registry is empty (the app does not use \
3475                         #[inspectable]/register_command_names), so coverage_pct is a \
3476                         placeholder 100%. `invoked_not_registered` still lists the real \
3477                         commands seen on the live IPC log — use it to inventory actual \
3478                         traffic.",
3479                    )
3480                } else if ipc_calls_observed == 0 {
3481                    Some(
3482                        "No IPC calls were observed on the live log. If the app is actively \
3483                         making calls, confirm the target webview and that Tauri IPC routes \
3484                         through fetch to ipc.localhost (some commands use the native channel).",
3485                    )
3486                } else {
3487                    None
3488                };
3489
3490                let result = serde_json::json!({
3491                    "registered_commands": registered.len(),
3492                    "invoked_commands": invoked.len(),
3493                    "ipc_calls_observed": ipc_calls_observed,
3494                    "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
3495                    "uncovered": uncovered,
3496                    "invoked_not_registered": invoked.iter()
3497                        .filter(|cmd| !registered.contains(cmd))
3498                        .collect::<Vec<_>>(),
3499                    "note": note,
3500                });
3501                json_result(&result)
3502            }
3503            IntrospectAction::CommandCatalog => {
3504                // Mine the live IPC log for per-command argument + result SHAPES (inferred
3505                // in JS, bodies never shipped — so it stays under the eval cap on busy apps)
3506                // and merge with the #[inspectable] registry. This is the answer to a real
3507                // live gap: an app without #[inspectable] (e.g. 4DA — 379 commands, every
3508                // registry field null) gives an agent command NAMES but no call/return
3509                // schema; the IPC log holds the actual shapes, so we project them out.
3510                let code = ipc_catalog_projection_js();
3511                let ipc_entries: Vec<serde_json::Value> = match self
3512                    .eval_with_return(&code, params.webview_label.as_deref())
3513                    .await
3514                {
3515                    Ok(json_str) => page_json::parse_page_json(&json_str).unwrap_or_default(),
3516                    Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
3517                };
3518
3519                let registry = self.state.registry.list();
3520                let catalog = merge_command_catalog(&ipc_entries, &registry);
3521                let observed = catalog
3522                    .iter()
3523                    .filter(|c| {
3524                        c.get("observed")
3525                            .and_then(serde_json::Value::as_bool)
3526                            .unwrap_or(false)
3527                    })
3528                    .count();
3529
3530                let result = serde_json::json!({
3531                    "catalog": catalog,
3532                    "observed_count": observed,
3533                    "registered_count": registry.len(),
3534                    "total": catalog.len(),
3535                    "note": "`arg_shape`/`result_shape` are STRUCTURES inferred from the live \
3536                             IPC log (keys + value types, not values) — how to CALL each command \
3537                             and what it RETURNS, even for apps that don't use #[inspectable]. \
3538                             `observed:false` means the command is in the registry but hasn't \
3539                             been seen on the wire this session (drive the app's UI to populate \
3540                             its shape). `declared_*` fields, when present, come from \
3541                             #[inspectable] and are authoritative over the inferred shape.",
3542                });
3543                json_result(&result)
3544            }
3545            IntrospectAction::ContractRecord => {
3546                let Some(command) = params.command else {
3547                    return missing_param("command", "contract_record");
3548                };
3549                // contract_record invokes the command with caller-supplied args, so
3550                // it must honour the same allow/blocklist as invoke_command (audit #30).
3551                if !self.state.privacy.is_invoke_allowed(&command)
3552                    || !self.state.privacy.is_command_allowed(&command)
3553                {
3554                    return tool_error(format!(
3555                        "command '{command}' is blocked by privacy configuration"
3556                    ));
3557                }
3558                let args_json = params.args.unwrap_or(serde_json::json!({}));
3559                let args_str =
3560                    serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
3561                let code = format!(
3562                    "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
3563                    js_string(&command)
3564                );
3565                match self
3566                    .eval_with_return(&code, params.webview_label.as_deref())
3567                    .await
3568                {
3569                    Ok(result_str) => {
3570                        let value: serde_json::Value = serde_json::from_str(&result_str)
3571                            .unwrap_or(serde_json::Value::String(result_str.clone()));
3572                        let shape = crate::introspection::JsonShape::from_value(&value);
3573                        let sample = if result_str.len() > 4096 {
3574                            format!(
3575                                "{}...(truncated)",
3576                                truncate_at_char_boundary(&result_str, 4096)
3577                            )
3578                        } else {
3579                            result_str
3580                        };
3581                        let baseline = crate::introspection::ContractBaseline {
3582                            command: command.clone(),
3583                            args: args_json,
3584                            shape: shape.clone(),
3585                            sample,
3586                            recorded_at: chrono_now(),
3587                        };
3588                        self.state.contract_store.record(baseline);
3589                        let result = serde_json::json!({
3590                            "recorded": true,
3591                            "command": command,
3592                            "shape_type": shape.type_name(),
3593                        });
3594                        json_result(&result)
3595                    }
3596                    Err(e) => tool_error(format!(
3597                        "failed to invoke '{command}' for contract recording: {e}"
3598                    )),
3599                }
3600            }
3601            IntrospectAction::ContractCheck => {
3602                let baselines = self.state.contract_store.all();
3603                if baselines.is_empty() {
3604                    return json_result(&serde_json::json!({
3605                        "checked": 0,
3606                        "message": "no contract baselines recorded — use contract_record first",
3607                    }));
3608                }
3609                let mut results = Vec::new();
3610                for baseline in &baselines {
3611                    // Re-checking a baseline re-invokes the command; honour the
3612                    // allow/blocklist in case it changed since recording (audit #30).
3613                    if !self.state.privacy.is_invoke_allowed(&baseline.command)
3614                        || !self.state.privacy.is_command_allowed(&baseline.command)
3615                    {
3616                        continue;
3617                    }
3618                    let args_str =
3619                        serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
3620                    let code = format!(
3621                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
3622                        js_string(&baseline.command)
3623                    );
3624                    match self
3625                        .eval_with_return(&code, params.webview_label.as_deref())
3626                        .await
3627                    {
3628                        Ok(result_str) => {
3629                            let value: serde_json::Value = serde_json::from_str(&result_str)
3630                                .unwrap_or(serde_json::Value::String(result_str));
3631                            let current_shape = crate::introspection::JsonShape::from_value(&value);
3632                            let drift = crate::introspection::diff_shapes(
3633                                &baseline.shape,
3634                                &current_shape,
3635                                &baseline.command,
3636                            );
3637                            results.push(drift);
3638                        }
3639                        Err(e) => {
3640                            results.push(crate::introspection::ContractDrift {
3641                                command: baseline.command.clone(),
3642                                new_fields: Vec::new(),
3643                                removed_fields: Vec::new(),
3644                                type_changes: Vec::new(),
3645                                shape_matches: false,
3646                            });
3647                            tracing::warn!(
3648                                command = %baseline.command,
3649                                error = %e,
3650                                "contract check invocation failed"
3651                            );
3652                        }
3653                    }
3654                }
3655                let passing = results.iter().filter(|r| r.shape_matches).count();
3656                let result = serde_json::json!({
3657                    "checked": results.len(),
3658                    "passing": passing,
3659                    "failing": results.len() - passing,
3660                    "contracts": results,
3661                });
3662                json_result(&result)
3663            }
3664            IntrospectAction::ContractList => {
3665                let baselines = self.state.contract_store.all();
3666                let result = serde_json::json!({
3667                    "count": baselines.len(),
3668                    "baselines": baselines.iter().map(|b| serde_json::json!({
3669                        "command": b.command,
3670                        "shape_type": b.shape.type_name(),
3671                        "recorded_at": b.recorded_at,
3672                    })).collect::<Vec<_>>(),
3673                });
3674                json_result(&result)
3675            }
3676            IntrospectAction::ContractClear => {
3677                let cleared = self.state.contract_store.clear();
3678                json_result(&serde_json::json!({
3679                    "cleared": cleared,
3680                }))
3681            }
3682            IntrospectAction::StartupTiming => {
3683                let phases = self.state.startup_timeline.report();
3684                let result = serde_json::json!({
3685                    "phases": phases,
3686                    "total_ms": self.state.startup_timeline.total_ms(),
3687                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
3688                });
3689                json_result(&result)
3690            }
3691            IntrospectAction::Capabilities => {
3692                let config = self.bridge.tauri_config();
3693                // A busy UI is reported as such, never as "no live windows".
3694                let (live_windows, live_windows_error) = match self.bridge.try_list_window_labels()
3695                {
3696                    Ok(labels) => (Some(labels), None),
3697                    Err(e) => (None, Some(ui_busy(&e))),
3698                };
3699
3700                let result = serde_json::json!({
3701                    "app": {
3702                        "identifier": config.get("identifier"),
3703                        "product_name": config.get("product_name"),
3704                        "version": config.get("version"),
3705                    },
3706                    "security": config.get("security"),
3707                    "configured_windows": config.get("windows"),
3708                    "live_windows": live_windows,
3709                    "live_windows_error": live_windows_error,
3710                    "configured_plugins": config.get("plugins"),
3711                    "victauri": {
3712                        "registered_commands": self.state.registry.list().len(),
3713                        "redaction_enabled": self.state.privacy.redaction_enabled,
3714                        "privacy_profile": format!("{:?}", self.state.privacy.profile),
3715                        "disabled_tools": &self.state.privacy.disabled_tools,
3716                    },
3717                });
3718                json_result(&result)
3719            }
3720            #[allow(unused_variables)]
3721            IntrospectAction::DbHealth => {
3722                #[cfg(feature = "sqlite")]
3723                {
3724                    let db_path = params.db_path.clone();
3725                    match self.run_db_health(db_path.as_deref()).await {
3726                        Ok(health) => json_result(&health),
3727                        Err(e) => tool_error(format!("db_health failed: {e}")),
3728                    }
3729                }
3730                #[cfg(not(feature = "sqlite"))]
3731                {
3732                    tool_error("SQLite support not compiled in — enable the `sqlite` feature")
3733                }
3734            }
3735            IntrospectAction::PluginState => {
3736                let recording_active = self.state.recorder.is_recording();
3737                let recording_events = self.state.recorder.event_count();
3738                let result = serde_json::json!({
3739                    "event_log": {
3740                        "size": self.state.event_log.len(),
3741                        "capacity": self.state.event_log.capacity(),
3742                    },
3743                    "registry": {
3744                        "commands_registered": self.state.registry.list().len(),
3745                    },
3746                    "recording": {
3747                        "active": recording_active,
3748                        "events_captured": recording_events,
3749                    },
3750                    "faults": {
3751                        "active_rules": self.state.fault_registry.list().len(),
3752                    },
3753                    "contracts": {
3754                        "baselines_recorded": self.state.contract_store.all().len(),
3755                    },
3756                    "timings": {
3757                        "commands_profiled": self.state.command_timings.all_stats().len(),
3758                    },
3759                    "event_bus": {
3760                        "captured_events": self.state.event_bus.len(),
3761                    },
3762                    "tasks": {
3763                        "total": self.state.task_tracker.list().len(),
3764                        "active": self.state.task_tracker.active_count(),
3765                    },
3766                    "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
3767                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
3768                    "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
3769                });
3770                json_result(&result)
3771            }
3772            IntrospectAction::Processes => {
3773                let pid = std::process::id();
3774                let uptime = self.state.started_at.elapsed();
3775                let children = crate::introspection::enumerate_child_processes();
3776                let host_memory = crate::memory::current_stats();
3777
3778                let result = serde_json::json!({
3779                    "host": {
3780                        "pid": pid,
3781                        "uptime_secs": uptime.as_secs(),
3782                        "platform": std::env::consts::OS,
3783                        "arch": std::env::consts::ARCH,
3784                        "memory": host_memory,
3785                    },
3786                    "children": children.iter().map(|c| serde_json::json!({
3787                        "pid": c.pid,
3788                        "name": c.name,
3789                        "memory_bytes": c.memory_bytes,
3790                    })).collect::<Vec<_>>(),
3791                    "child_count": children.len(),
3792                    "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
3793                });
3794                json_result(&result)
3795            }
3796            IntrospectAction::PluginTasks => {
3797                let tasks = self.state.task_tracker.list();
3798                let active = self.state.task_tracker.active_count();
3799                let result = serde_json::json!({
3800                    "total": tasks.len(),
3801                    "active": active,
3802                    "finished": tasks.len() - active,
3803                    "tasks": tasks,
3804                });
3805                json_result(&result)
3806            }
3807            IntrospectAction::EventBus => {
3808                // Default cap so the full buffers (up to 1k Tauri + 10k app events, often
3809                // megabytes / tens of thousands of lines) can never overflow the tool result
3810                // cap (VIC-4). Newest events first; `count` is the full total so a truncated
3811                // slice is always diagnosable. Optional `limit` / `since_ms` are read from the
3812                // generic `args` object (a dedicated public field would be a semver-major break).
3813                let opts = params.args.as_ref();
3814                let limit = opts
3815                    .and_then(|a| a.get("limit"))
3816                    .and_then(serde_json::Value::as_u64)
3817                    .and_then(|n| usize::try_from(n).ok())
3818                    .unwrap_or(100);
3819                let since_ms = opts
3820                    .and_then(|a| a.get("since_ms"))
3821                    .and_then(serde_json::Value::as_u64);
3822                let cutoff = since_ms.map(|ms| bounded::ms_ago(chrono::Utc::now(), ms));
3823
3824                let all_tauri = self.state.event_bus.events();
3825                let tauri_total = all_tauri.len();
3826                let tauri_matched: Vec<_> = all_tauri
3827                    .into_iter()
3828                    .filter(|e| match cutoff {
3829                        Some(cut) => chrono::DateTime::parse_from_rfc3339(&e.timestamp)
3830                            .map_or(true, |t| t.with_timezone(&chrono::Utc) >= cut),
3831                        None => true,
3832                    })
3833                    .collect();
3834                let tauri_matched_count = tauri_matched.len();
3835                let tauri_events: Vec<_> = tauri_matched.into_iter().rev().take(limit).collect();
3836
3837                // Exclude Victauri's own infrastructure events (plugin:victauri|* IPC etc.) —
3838                // noise in a diagnostic timeline; the `explain` tools already filter them via
3839                // `is_internal()`.
3840                let all_app: Vec<_> = self
3841                    .state
3842                    .event_log
3843                    .snapshot()
3844                    .into_iter()
3845                    .filter(|e| !e.is_internal())
3846                    .collect();
3847                let app_total = all_app.len();
3848                let app_matched: Vec<_> = match cutoff {
3849                    Some(cut) => all_app
3850                        .into_iter()
3851                        .filter(|e| e.timestamp() >= cut)
3852                        .collect(),
3853                    None => all_app,
3854                };
3855                let app_matched_count = app_matched.len();
3856                let app_events: Vec<_> = app_matched.into_iter().rev().take(limit).collect();
3857
3858                let result = serde_json::json!({
3859                    "limit": limit,
3860                    "since_ms": since_ms,
3861                    "tauri_events": {
3862                        "count": tauri_total,
3863                        "matched": tauri_matched_count,
3864                        "returned": tauri_events.len(),
3865                        "truncated": tauri_matched_count > tauri_events.len(),
3866                        "events": tauri_events,
3867                    },
3868                    "app_events": {
3869                        "count": app_total,
3870                        "matched": app_matched_count,
3871                        "returned": app_events.len(),
3872                        "truncated": app_matched_count > app_events.len(),
3873                        "capacity": self.state.event_log.capacity(),
3874                        "events": app_events,
3875                    },
3876                });
3877                json_result(&result)
3878            }
3879            IntrospectAction::EventBusClear => {
3880                let tauri_cleared = self.state.event_bus.clear();
3881                self.state.event_log.clear();
3882                json_result(&serde_json::json!({
3883                    "tauri_events_cleared": tauri_cleared,
3884                    "app_events_cleared": true,
3885                }))
3886            }
3887        }
3888    }
3889
3890    // ── Fault Injection / Chaos Engineering ──────────────────────────────────
3891
3892    #[tool(
3893        description = "Probe a backend command handler under failure by faulting it for chaos engineering. \
3894            Simulate slow commands, backend errors, dropped responses, and corrupted data. \
3895            SCOPE: faults apply ONLY to commands you run via this server's `invoke_command` tool — \
3896            they do NOT intercept the app's real user-driven IPC (window.__TAURI_INTERNALS__.invoke), \
3897            which runs below the layer Victauri can reach. Use this to test a handler's error path when \
3898            YOU drive it; it does not reproduce a failure a user clicking the UI would see.\n\n\
3899            Actions:\n\
3900            - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms` \
3901              (max 120000), `error_message`, `max_triggers`. `delay` sleeps then runs the command; \
3902              `error` returns the error without running it; `drop` returns `{}` without running \
3903              it; `corrupt` runs it and replaces the response with a fixed \
3904              `{\"__corrupted\":true,…}` marker. Rules expire after 15 minutes.\n\
3905            - `list`: List all active fault injection rules.\n\
3906            - `clear`: Remove a specific fault rule (requires `command`).\n\
3907            - `clear_all`: Remove all fault rules.",
3908        annotations(
3909            read_only_hint = false,
3910            destructive_hint = true,
3911            idempotent_hint = false,
3912            open_world_hint = false
3913        )
3914    )]
3915    async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
3916        if !self.state.privacy.is_tool_enabled("fault") {
3917            return tool_disabled("fault");
3918        }
3919
3920        match params.action {
3921            FaultAction::Inject => {
3922                let Some(command) = params.command else {
3923                    return missing_param("command", "inject");
3924                };
3925                let Some(fault_kind) = params.fault_type else {
3926                    return missing_param("fault_type", "inject");
3927                };
3928                let fault_type = match fault_kind {
3929                    FaultKind::Delay => {
3930                        let delay_ms = params.delay_ms.unwrap_or(1000);
3931                        // Each delayed call holds a server concurrency slot for its duration;
3932                        // an unbounded delay (u64 ms) would pin slots effectively forever.
3933                        if delay_ms > MAX_FAULT_DELAY_MS {
3934                            return tool_error(format!(
3935                                "delay_ms {delay_ms} exceeds the maximum of {MAX_FAULT_DELAY_MS} ms"
3936                            ));
3937                        }
3938                        crate::introspection::FaultType::Delay { delay_ms }
3939                    }
3940                    FaultKind::Error => {
3941                        let message = params
3942                            .error_message
3943                            .unwrap_or_else(|| "injected fault".to_string());
3944                        crate::introspection::FaultType::Error { message }
3945                    }
3946                    FaultKind::Drop => crate::introspection::FaultType::Drop,
3947                    FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
3948                };
3949                let config = crate::introspection::FaultConfig {
3950                    command: command.clone(),
3951                    fault_type: fault_type.clone(),
3952                    trigger_count: 0,
3953                    max_triggers: params.max_triggers.unwrap_or(0),
3954                    created_at: std::time::Instant::now(),
3955                };
3956                self.state.fault_registry.inject(config);
3957                let result = serde_json::json!({
3958                    "injected": true,
3959                    "command": command,
3960                    "fault_type": fault_type,
3961                    "max_triggers": params.max_triggers.unwrap_or(0),
3962                });
3963                json_result(&result)
3964            }
3965            FaultAction::List => {
3966                let faults = self.state.fault_registry.list();
3967                let result = serde_json::json!({
3968                    "count": faults.len(),
3969                    "faults": faults.iter().map(|f| serde_json::json!({
3970                        "command": f.command,
3971                        "fault_type": f.fault_type,
3972                        "trigger_count": f.trigger_count,
3973                        "max_triggers": f.max_triggers,
3974                    })).collect::<Vec<_>>(),
3975                });
3976                json_result(&result)
3977            }
3978            FaultAction::Clear => {
3979                let Some(command) = params.command else {
3980                    return missing_param("command", "clear");
3981                };
3982                let removed = self.state.fault_registry.clear(&command);
3983                json_result(&serde_json::json!({
3984                    "removed": removed,
3985                    "command": command,
3986                }))
3987            }
3988            FaultAction::ClearAll => {
3989                let removed = self.state.fault_registry.clear_all();
3990                json_result(&serde_json::json!({
3991                    "removed": removed,
3992                }))
3993            }
3994        }
3995    }
3996
3997    // ── Cross-Layer Explanation ────────────────────────────────────────────
3998
3999    #[tool(
4000        description = "Correlate recent activity across all layers into a coherent narrative. \
4001            CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
4002            + window events across the Rust backend and webview simultaneously.\n\n\
4003            Actions:\n\
4004            - `summary`: High-level activity summary for the last N seconds (default 30). \
4005              Counts IPC calls, DOM mutations, console entries, state changes (incl. network \
4006              requests), errors. With no recording active it reads the window's live event \
4007              stream (`webview_label`, default main).\n\
4008            - `last_action`: Correlate the most recent burst of events into a causal timeline \
4009              (e.g. 'IPC call → DOM update → console.log').\n\
4010            - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
4011        annotations(
4012            read_only_hint = true,
4013            destructive_hint = false,
4014            idempotent_hint = true,
4015            open_world_hint = false
4016        )
4017    )]
4018    async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
4019        if !self.state.privacy.is_tool_enabled("explain") {
4020            return tool_disabled("explain");
4021        }
4022
4023        match params.action {
4024            ExplainAction::Summary => {
4025                let secs = params.seconds.unwrap_or(30);
4026                let since = bounded::secs_ago(chrono::Utc::now(), secs);
4027                let events = self
4028                    .explain_events(since, params.webview_label.as_deref())
4029                    .await;
4030
4031                let mut ipc_count = 0u64;
4032                let mut dom_mutations = 0u64;
4033                let mut state_changes = 0u64;
4034                let mut console_count = 0u64;
4035                let mut window_events = 0u64;
4036                let mut interactions = 0u64;
4037                let mut top_commands: HashMap<String, u64> = HashMap::new();
4038                let mut errors: Vec<String> = Vec::new();
4039
4040                for event in &events {
4041                    match event {
4042                        victauri_core::AppEvent::Ipc(call) => {
4043                            ipc_count += 1;
4044                            *top_commands.entry(call.command.clone()).or_insert(0) += 1;
4045                            if let victauri_core::IpcResult::Err(e) = &call.result {
4046                                errors.push(format!("IPC {}: {e}", call.command));
4047                            }
4048                        }
4049                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
4050                            dom_mutations += u64::from(*mutation_count)
4051                        }
4052                        victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
4053                        victauri_core::AppEvent::Console { level, message, .. } => {
4054                            console_count += 1;
4055                            if level == "error" {
4056                                errors.push(format!("console.error: {message}"));
4057                            }
4058                        }
4059                        victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
4060                        victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
4061                        _ => {}
4062                    }
4063                }
4064
4065                let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
4066                sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
4067                let top: Vec<_> = sorted_cmds.iter().take(5).collect();
4068
4069                let narrative = format!(
4070                    "{ipc_count} IPC call{} in the last {secs}s{}. \
4071                     {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
4072                     {console_count} console message{}, {window_events} window event{}. {}.",
4073                    if ipc_count == 1 { "" } else { "s" },
4074                    if top.is_empty() {
4075                        String::new()
4076                    } else {
4077                        format!(
4078                            ", dominated by {}",
4079                            top.iter()
4080                                .map(|(cmd, n)| format!("{cmd} ({n}x)"))
4081                                .collect::<Vec<_>>()
4082                                .join(", ")
4083                        )
4084                    },
4085                    if dom_mutations == 1 { "" } else { "s" },
4086                    if interactions == 1 { "" } else { "s" },
4087                    if console_count == 1 { "" } else { "s" },
4088                    if window_events == 1 { "" } else { "s" },
4089                    if errors.is_empty() {
4090                        "No errors".to_string()
4091                    } else {
4092                        format!(
4093                            "{} error{}",
4094                            errors.len(),
4095                            if errors.len() == 1 { "" } else { "s" }
4096                        )
4097                    },
4098                );
4099
4100                let result = serde_json::json!({
4101                    "time_window_secs": secs,
4102                    "total_events": events.len(),
4103                    "ipc_calls": ipc_count,
4104                    "dom_mutations": dom_mutations,
4105                    "state_changes": state_changes,
4106                    "console_messages": console_count,
4107                    "window_events": window_events,
4108                    "interactions": interactions,
4109                    "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
4110                        serde_json::json!({"command": cmd, "count": n})
4111                    }).collect::<Vec<_>>(),
4112                    "errors": errors,
4113                    "narrative": narrative,
4114                });
4115                json_result(&result)
4116            }
4117            ExplainAction::LastAction => {
4118                let secs = params.seconds.unwrap_or(5);
4119                let since = bounded::secs_ago(chrono::Utc::now(), secs);
4120                let events = self
4121                    .explain_events(since, params.webview_label.as_deref())
4122                    .await;
4123
4124                let timeline: Vec<serde_json::Value> = events
4125                    .iter()
4126                    .filter(|e| !e.is_internal())
4127                    .map(|event| match event {
4128                        victauri_core::AppEvent::Ipc(call) => serde_json::json!({
4129                            "time": call.timestamp.to_rfc3339_opts(
4130                                chrono::SecondsFormat::Millis, true
4131                            ),
4132                            "type": "ipc",
4133                            "detail": format!(
4134                                "{} {} ({}ms)",
4135                                call.command,
4136                                call.result,
4137                                call.duration_ms.unwrap_or(0)
4138                            ),
4139                        }),
4140                        victauri_core::AppEvent::DomMutation {
4141                            timestamp,
4142                            mutation_count,
4143                            webview_label,
4144                            ..
4145                        } => serde_json::json!({
4146                            "time": timestamp.to_rfc3339_opts(
4147                                chrono::SecondsFormat::Millis, true
4148                            ),
4149                            "type": "dom_mutation",
4150                            "detail": format!(
4151                                "{mutation_count} element{} updated in {webview_label}",
4152                                if *mutation_count == 1 { "" } else { "s" }
4153                            ),
4154                        }),
4155                        victauri_core::AppEvent::DomInteraction {
4156                            timestamp,
4157                            action,
4158                            selector,
4159                            ..
4160                        } => serde_json::json!({
4161                            "time": timestamp.to_rfc3339_opts(
4162                                chrono::SecondsFormat::Millis, true
4163                            ),
4164                            "type": "interaction",
4165                            "detail": format!("{action} on {selector}"),
4166                        }),
4167                        victauri_core::AppEvent::StateChange {
4168                            timestamp,
4169                            key,
4170                            caused_by,
4171                            ..
4172                        } => serde_json::json!({
4173                            "time": timestamp.to_rfc3339_opts(
4174                                chrono::SecondsFormat::Millis, true
4175                            ),
4176                            "type": "state_change",
4177                            "detail": format!(
4178                                "{key} changed{}",
4179                                caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
4180                            ),
4181                        }),
4182                        victauri_core::AppEvent::Console {
4183                            timestamp,
4184                            level,
4185                            message,
4186                            ..
4187                        } => serde_json::json!({
4188                            "time": timestamp.to_rfc3339_opts(
4189                                chrono::SecondsFormat::Millis, true
4190                            ),
4191                            "type": "console",
4192                            "detail": format!("console.{level}: {message}"),
4193                        }),
4194                        victauri_core::AppEvent::WindowEvent {
4195                            timestamp,
4196                            label,
4197                            event,
4198                            ..
4199                        } => serde_json::json!({
4200                            "time": timestamp.to_rfc3339_opts(
4201                                chrono::SecondsFormat::Millis, true
4202                            ),
4203                            "type": "window_event",
4204                            "detail": format!("{event} on window '{label}'"),
4205                        }),
4206                        _ => serde_json::json!({
4207                            "time": event.timestamp().to_rfc3339_opts(
4208                                chrono::SecondsFormat::Millis, true
4209                            ),
4210                            "type": "other",
4211                            "detail": "unknown event type",
4212                        }),
4213                    })
4214                    .collect();
4215
4216                let narrative = if timeline.is_empty() {
4217                    format!("No activity in the last {secs}s.")
4218                } else {
4219                    let parts: Vec<String> = timeline
4220                        .iter()
4221                        .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
4222                        .map(String::from)
4223                        .collect();
4224                    parts.join(" → ")
4225                };
4226
4227                let result = serde_json::json!({
4228                    "time_window_secs": secs,
4229                    "event_count": timeline.len(),
4230                    "timeline": timeline,
4231                    "narrative": narrative,
4232                });
4233                json_result(&result)
4234            }
4235            ExplainAction::Diff => {
4236                let secs = params.seconds.unwrap_or(10);
4237                let since = bounded::secs_ago(chrono::Utc::now(), secs);
4238                let events = self
4239                    .explain_events(since, params.webview_label.as_deref())
4240                    .await;
4241
4242                let mut ipc_commands: Vec<String> = Vec::new();
4243                let mut dom_changes = 0u64;
4244                let mut error_count = 0u64;
4245                let mut interaction_count = 0u64;
4246                let mut console_messages = 0u64;
4247
4248                for event in &events {
4249                    if event.is_internal() {
4250                        continue;
4251                    }
4252                    match event {
4253                        victauri_core::AppEvent::Ipc(call) => {
4254                            ipc_commands.push(call.command.clone());
4255                            if matches!(call.result, victauri_core::IpcResult::Err(_)) {
4256                                error_count += 1;
4257                            }
4258                        }
4259                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
4260                            dom_changes += u64::from(*mutation_count)
4261                        }
4262                        victauri_core::AppEvent::DomInteraction { .. } => {
4263                            interaction_count += 1;
4264                        }
4265                        victauri_core::AppEvent::Console { level, .. } => {
4266                            console_messages += 1;
4267                            if level == "error" {
4268                                error_count += 1;
4269                            }
4270                        }
4271                        _ => {}
4272                    }
4273                }
4274
4275                // Every call counts; each command is listed once, in first-call order (a
4276                // consecutive-only `dedup` used to undercount the calls and still list a
4277                // command once per non-adjacent run).
4278                let ipc_calls_made = ipc_commands.len();
4279                let mut seen = HashSet::new();
4280                ipc_commands.retain(|c| seen.insert(c.clone()));
4281
4282                let result = serde_json::json!({
4283                    "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
4284                    "time_window_secs": secs,
4285                    "total_events": events.len(),
4286                    "ipc_calls_made": ipc_calls_made,
4287                    "unique_commands": ipc_commands,
4288                    "dom_elements_changed": dom_changes,
4289                    "interactions": interaction_count,
4290                    "console_messages": console_messages,
4291                    "errors": error_count,
4292                });
4293                json_result(&result)
4294            }
4295        }
4296    }
4297}
4298
4299impl VictauriMcpHandler {
4300    /// Create a new handler backed by the given state and webview bridge.
4301    pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
4302        Self {
4303            state,
4304            bridge,
4305            subscriptions: Arc::new(Mutex::new(HashSet::new())),
4306            bridge_checked: Arc::new(AtomicBool::new(false)),
4307            timed_out_labels: Arc::new(Mutex::new(HashSet::new())),
4308            probe_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_PROBES)),
4309            file_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_FILE_READS)),
4310            probe_timeout: PROBE_TIMEOUT,
4311            file_read_timeout: READ_APP_FILE_TIMEOUT,
4312        }
4313    }
4314
4315    pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
4316        self.state.privacy.is_tool_enabled(name)
4317    }
4318
4319    pub(crate) async fn execute_tool(
4320        &self,
4321        name: &str,
4322        args: serde_json::Value,
4323    ) -> Result<CallToolResult, rest::ToolCallError> {
4324        // Centralized authorization: resolve the canonical `tool.action` capability
4325        // and gate on it BEFORE dispatch, so every compound action is checked
4326        // uniformly (not just the ones whose handler remembers to). See `authz`.
4327        // A disabled tool reports "disabled" whatever its arguments look like.
4328        if self.state.privacy.disabled_tools.contains(name) {
4329            return Ok(tool_disabled(name));
4330        }
4331        let capability =
4332            authz::resolve_capability(name, &args).map_err(rest::ToolCallError::InvalidParams)?;
4333        if !self.state.privacy.is_call_allowed(name, &capability) {
4334            return Ok(tool_disabled(&capability));
4335        }
4336        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
4337        let start = std::time::Instant::now();
4338        tracing::debug!(tool = %name, "REST tool invocation started");
4339
4340        // A panicking handler becomes an error RESULT; without this boundary it unwound into
4341        // hyper's connection task and the client saw a reset connection.
4342        let result = match bounded::CatchUnwind::new(self.dispatch_tool(name, args)).await {
4343            Ok(dispatched) => dispatched?,
4344            Err(panic) => {
4345                tracing::error!(tool = %name, "tool handler panicked: {panic}");
4346                tool_panicked(name, &panic)
4347            }
4348        };
4349
4350        let elapsed = start.elapsed();
4351        tracing::debug!(
4352            tool = %name,
4353            elapsed_ms = elapsed.as_millis() as u64,
4354            "REST tool invocation completed"
4355        );
4356
4357        if self.state.privacy.redaction_enabled {
4358            Ok(Self::redact_result(result, &self.state.privacy))
4359        } else {
4360            Ok(result)
4361        }
4362    }
4363
4364    /// Route one already-authorized REST call to its tool handler.
4365    async fn dispatch_tool(
4366        &self,
4367        name: &str,
4368        args: serde_json::Value,
4369    ) -> Result<CallToolResult, rest::ToolCallError> {
4370        let result = match name {
4371            "eval_js" => {
4372                let p: EvalJsParams = Self::parse_args(args)?;
4373                self.eval_js(Parameters(p)).await
4374            }
4375            "dom_snapshot" => {
4376                let p: SnapshotParams = Self::parse_args(args)?;
4377                self.dom_snapshot(Parameters(p)).await
4378            }
4379            "find_elements" => {
4380                let p: FindElementsParams = Self::parse_args(args)?;
4381                self.find_elements(Parameters(p)).await
4382            }
4383            "invoke_command" => {
4384                let p: InvokeCommandParams = Self::parse_args(args)?;
4385                self.invoke_command(Parameters(p)).await
4386            }
4387            "screenshot" => {
4388                let p: ScreenshotParams = Self::parse_args(args)?;
4389                self.screenshot(Parameters(p)).await
4390            }
4391            "verify_state" => {
4392                let p: VerifyStateParams = Self::parse_args(args)?;
4393                self.verify_state(Parameters(p)).await
4394            }
4395            "detect_ghost_commands" => {
4396                let p: GhostCommandParams = Self::parse_args(args)?;
4397                self.detect_ghost_commands(Parameters(p)).await
4398            }
4399            "check_ipc_integrity" => {
4400                let p: IpcIntegrityParams = Self::parse_args(args)?;
4401                self.check_ipc_integrity(Parameters(p)).await
4402            }
4403            "wait_for" => {
4404                let p: WaitForParams = Self::parse_args(args)?;
4405                self.wait_for(Parameters(p)).await
4406            }
4407            "assert_semantic" => {
4408                let p: SemanticAssertParams = Self::parse_args(args)?;
4409                self.assert_semantic(Parameters(p)).await
4410            }
4411            "resolve_command" => {
4412                let p: ResolveCommandParams = Self::parse_args(args)?;
4413                self.resolve_command(Parameters(p)).await
4414            }
4415            "get_registry" => {
4416                let p: RegistryParams = Self::parse_args(args)?;
4417                self.get_registry(Parameters(p)).await
4418            }
4419            "app_state" => {
4420                let p: AppStateParams = Self::parse_args(args)?;
4421                self.app_state(Parameters(p)).await
4422            }
4423            "get_memory_stats" => self.get_memory_stats().await,
4424            "get_plugin_info" => self.get_plugin_info().await,
4425            "get_diagnostics" => {
4426                let p: DiagnosticsParams = Self::parse_args(args)?;
4427                self.get_diagnostics(Parameters(p)).await
4428            }
4429            "app_info" => self.app_info().await,
4430            "list_app_dir" => {
4431                let p: ListAppDirParams = Self::parse_args(args)?;
4432                self.list_app_dir(Parameters(p)).await
4433            }
4434            "read_app_file" => {
4435                let p: ReadAppFileParams = Self::parse_args(args)?;
4436                self.read_app_file(Parameters(p)).await
4437            }
4438            "query_db" => {
4439                let p: QueryDbParams = Self::parse_args(args)?;
4440                self.query_db(Parameters(p)).await
4441            }
4442            "interact" => {
4443                let p: InteractParams = Self::parse_args(args)?;
4444                self.interact(Parameters(p)).await
4445            }
4446            "input" => {
4447                let p: InputParams = Self::parse_args(args)?;
4448                self.input(Parameters(p)).await
4449            }
4450            "window" => {
4451                let p: WindowParams = Self::parse_args(args)?;
4452                self.window(Parameters(p)).await
4453            }
4454            "storage" => {
4455                let p: StorageParams = Self::parse_args(args)?;
4456                self.storage(Parameters(p)).await
4457            }
4458            "navigate" => {
4459                let p: NavigateParams = Self::parse_args(args)?;
4460                self.navigate(Parameters(p)).await
4461            }
4462            "recording" => {
4463                let p: RecordingParams = Self::parse_args(args)?;
4464                self.recording(Parameters(p)).await
4465            }
4466            "inspect" => {
4467                let p: InspectParams = Self::parse_args(args)?;
4468                self.inspect(Parameters(p)).await
4469            }
4470            "css" => {
4471                let p: CssParams = Self::parse_args(args)?;
4472                self.css(Parameters(p)).await
4473            }
4474            "route" => {
4475                let p: RouteParams = Self::parse_args(args)?;
4476                self.route(Parameters(p)).await
4477            }
4478            "trace" => {
4479                let p: TraceParams = Self::parse_args(args)?;
4480                self.trace(Parameters(p)).await
4481            }
4482            "animation" => {
4483                let p: AnimationParams = Self::parse_args(args)?;
4484                self.animation(Parameters(p)).await
4485            }
4486            "logs" => {
4487                let p: LogsParams = Self::parse_args(args)?;
4488                self.logs(Parameters(p)).await
4489            }
4490            "introspect" => {
4491                let p: IntrospectParams = Self::parse_args(args)?;
4492                self.introspect(Parameters(p)).await
4493            }
4494            "fault" => {
4495                let p: FaultParams = Self::parse_args(args)?;
4496                self.fault(Parameters(p)).await
4497            }
4498            "explain" => {
4499                let p: ExplainParams = Self::parse_args(args)?;
4500                self.explain(Parameters(p)).await
4501            }
4502            _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
4503        };
4504        Ok(result)
4505    }
4506
4507    fn parse_args<T: serde::de::DeserializeOwned>(
4508        args: serde_json::Value,
4509    ) -> Result<T, rest::ToolCallError> {
4510        serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
4511    }
4512
4513    fn redact_result(
4514        mut result: CallToolResult,
4515        privacy: &crate::privacy::PrivacyConfig,
4516    ) -> CallToolResult {
4517        for item in &mut result.content {
4518            if let ContentBlock::Text(tc) = item {
4519                tc.text = privacy.redact_output(&tc.text);
4520            }
4521        }
4522        result
4523    }
4524
4525    /// Read at most `max_bytes` (+1, to detect truncation) of the regular file at `path` on
4526    /// the blocking pool, within [`READ_APP_FILE_TIMEOUT`] and one of the
4527    /// [`MAX_CONCURRENT_FILE_READS`] slots. A read that blocks (a FIFO or device swapped in
4528    /// after the handler's checks) returns at the deadline; its thread keeps the slot until
4529    /// it finishes, so such reads cannot pile up.
4530    async fn read_regular_file_bounded(
4531        &self,
4532        path: std::path::PathBuf,
4533        max_bytes: usize,
4534    ) -> Result<(Vec<u8>, usize, Option<u64>), String> {
4535        let Ok(slot) = Arc::clone(&self.file_slots).try_acquire_owned() else {
4536            return Err(format!(
4537                "file reads are busy ({MAX_CONCURRENT_FILE_READS} still running — a read \
4538                 blocked on a pipe or device keeps running past its timeout). Retry shortly."
4539            ));
4540        };
4541        bounded::run_blocking_bounded(None, "file read", self.file_read_timeout, move || {
4542            let _slot = slot;
4543            read_regular_file(&path, max_bytes)
4544        })
4545        .await
4546    }
4547
4548    fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
4549        match dir.unwrap_or(AppDir::Data) {
4550            AppDir::Data => self.bridge.app_data_dir(),
4551            AppDir::Config => self.bridge.app_config_dir(),
4552            AppDir::Log => self.bridge.app_log_dir(),
4553            AppDir::LocalData => self.bridge.app_local_data_dir(),
4554        }
4555    }
4556
4557    /// Lexical (pre-existence) traversal guard for a user-supplied sub-path.
4558    ///
4559    /// Rejects absolute paths and any component that is `..` BEFORE the path is
4560    /// canonicalized. This is necessary because [`Self::safe_within`] relies on
4561    /// `canonicalize`, which errors on non-existent paths — so a traversal
4562    /// attempt against a missing target would otherwise be reported as
4563    /// "not found" (an info-leak oracle) rather than as traversal.
4564    fn lexical_safe(sub: &std::path::Path) -> Result<(), String> {
4565        use std::path::Component;
4566        if sub.is_absolute() {
4567            return Err("path traversal not allowed: absolute paths are rejected".to_string());
4568        }
4569        for component in sub.components() {
4570            match component {
4571                Component::ParentDir => {
4572                    return Err("path traversal not allowed: '..' is rejected".to_string());
4573                }
4574                Component::Prefix(_) | Component::RootDir => {
4575                    return Err(
4576                        "path traversal not allowed: absolute paths are rejected".to_string()
4577                    );
4578                }
4579                Component::CurDir | Component::Normal(_) => {}
4580            }
4581        }
4582        Ok(())
4583    }
4584
4585    fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
4586        let canon_base = std::fs::canonicalize(base)
4587            .map_err(|e| format!("cannot resolve base directory: {e}"))?;
4588        let canon_target = std::fs::canonicalize(target)
4589            .map_err(|e| format!("cannot resolve target path: {e}"))?;
4590        if !canon_target.starts_with(&canon_base) {
4591            return Err("path traversal not allowed".to_string());
4592        }
4593        Ok(())
4594    }
4595
4596    #[cfg(feature = "sqlite")]
4597    fn resolve_existing_db_path(
4598        roots: &[std::path::PathBuf],
4599        requested: &str,
4600    ) -> Result<std::path::PathBuf, String> {
4601        let candidate = std::path::Path::new(requested);
4602        // One answer for "missing" and "resolves outside every root" (a symlink inside a root
4603        // pointing elsewhere): distinct answers told the caller whether an arbitrary path
4604        // exists on disk.
4605        let not_found = || {
4606            format!(
4607                "database not found (or it resolves outside the allowed directories): {requested}"
4608            )
4609        };
4610        if candidate.is_absolute() {
4611            // `root/link/../y` normalizes lexically to `root/y`, but the OS resolves `..` AFTER
4612            // following `link` — so the existence check probed a path outside every root.
4613            if candidate
4614                .components()
4615                .any(|c| matches!(c, std::path::Component::ParentDir))
4616            {
4617                return Err("path traversal not allowed: '..' is rejected".to_string());
4618            }
4619            // Decide containment LEXICALLY first, before touching the requested path: checking
4620            // existence first answered "does X exist?" for any path on disk ("not found" vs
4621            // "not within an allowed directory"). The canonical check below still catches
4622            // symlink escapes for paths that are lexically inside a root.
4623            let normalized = lexically_normalize(candidate);
4624            let lexically_inside = roots.iter().any(|root| {
4625                normalized.starts_with(lexically_normalize(root))
4626                    || std::fs::canonicalize(root)
4627                        .is_ok_and(|canon_root| normalized.starts_with(canon_root))
4628            });
4629            if !lexically_inside {
4630                return Err(format!(
4631                    "absolute path '{requested}' is not within an allowed directory; \
4632                     register its parent via VictauriBuilder::db_search_paths"
4633                ));
4634            }
4635            if candidate.exists()
4636                && roots
4637                    .iter()
4638                    .any(|root| Self::safe_within(root, candidate).is_ok())
4639            {
4640                // Open the CANONICAL validated path, not the caller's literal absolute path,
4641                // so the DB is opened at exactly the containment-approved location — symmetric
4642                // with the relative branch below and closing the validate-canonical/open-lexical
4643                // TOCTOU on this branch (a same-privilege symlink swap between canonicalize and
4644                // open is the unavoidable residual, documented in security.md).
4645                let canonical = std::fs::canonicalize(candidate)
4646                    .map_err(|e| format!("cannot resolve database path: {e}"))?;
4647                return Ok(canonical);
4648            }
4649            return Err(not_found());
4650        }
4651
4652        Self::lexical_safe(candidate)?;
4653        for root in roots {
4654            let resolved = root.join(candidate);
4655            // A match that escapes its root is treated exactly like a miss (see `not_found`).
4656            if resolved.exists() && Self::safe_within(root, &resolved).is_ok() {
4657                // Open the CANONICAL validated path, not the lexical join, so the DB is opened
4658                // at exactly the path containment approved (closes the trivial validate-lexical
4659                // vs open-lexical TOCTOU; a same-privilege local symlink swap between
4660                // canonicalize and open is the unavoidable residual, documented in security.md).
4661                let canonical = std::fs::canonicalize(&resolved)
4662                    .map_err(|e| format!("cannot resolve database path: {e}"))?;
4663                return Ok(canonical);
4664            }
4665        }
4666
4667        let roots = roots
4668            .iter()
4669            .map(|root| root.display().to_string())
4670            .collect::<Vec<_>>()
4671            .join(", ");
4672        Err(format!("{} (searched: {roots})", not_found()))
4673    }
4674
4675    /// Events for `explain` since `since`. While a recording is active the background drain
4676    /// keeps `event_log` current, so read it. Otherwise nothing drains (idle draining was the
4677    /// 0.8.x host-crash amplifier), so read the bridge's own event stream ON DEMAND — without
4678    /// writing it into `event_log`, so a later recording never double-counts it. Before this,
4679    /// `explain` silently reported "0 IPC calls, 0 DOM mutations…" for any app not recording.
4680    async fn explain_events(
4681        &self,
4682        since: chrono::DateTime<chrono::Utc>,
4683        webview_label: Option<&str>,
4684    ) -> Vec<victauri_core::AppEvent> {
4685        if self.state.recorder.is_recording() {
4686            return self.state.event_log.since(since);
4687        }
4688        let code = format!(
4689            "return window.__VICTAURI__?.getEventStream({})",
4690            since.timestamp_millis()
4691        );
4692        let Ok(raw) = self.eval_with_return(&code, webview_label).await else {
4693            return self.state.event_log.since(since);
4694        };
4695        let label = webview_label.unwrap_or("main");
4696        let mut events: Vec<victauri_core::AppEvent> =
4697            page_json::parse_page_json::<Vec<serde_json::Value>>(&raw)
4698                .unwrap_or_default()
4699                .iter()
4700                .filter_map(|ev| crate::mcp::server::parse_bridge_event_from(ev, label))
4701                .filter(|ev| !ev.is_internal() && ev.timestamp() >= since)
4702                .collect();
4703        events.sort_by_key(victauri_core::AppEvent::timestamp);
4704        events
4705    }
4706
4707    /// Resolve the EXACT window a native capture (`screenshot`, `trace`) should target, and
4708    /// require it to be visible. A native capture reads the on-screen surface; a hidden window
4709    /// has none, so the OS path (`PrintWindow` / `CGWindowListCreateImage`) silently returns
4710    /// stale, empty, or ANOTHER window's pixels with no error. Two failure modes are closed:
4711    ///   1. (live-4DA, 2026-06-16) an explicitly-requested hidden window (label:"briefing")
4712    ///      returned the MAIN window's pixels.
4713    ///   2. (GPT audit, P2) with no label, `find_window(None)` prefers "main" UNCONDITIONALLY,
4714    ///      so an app that hides main but leaves a secondary window visible captured hidden
4715    ///      main. `find_window` is left alone — other callers (e.g. eval) may legitimately
4716    ///      target a hidden window — so capture tools resolve their own VISIBLE target.
4717    ///
4718    /// Acknowledged residual: resolve -> handle -> capture are separate calls, so a window
4719    /// hidden in the gap is still TOCTOU; the worst case is a wrong image, not a security
4720    /// boundary.
4721    fn resolve_visible_capture_target(&self, label: Option<&str>) -> Result<String, String> {
4722        let states = self
4723            .bridge
4724            .try_get_window_states(None)
4725            .map_err(|e| ui_busy(&e))?;
4726        if let Some(label) = label {
4727            // An explicit label that the enumerator reports hidden is rejected; visible or
4728            // unknown labels fall through (an unknown one lets get_native_handle produce the
4729            // canonical "window not found" rather than inventing an error here).
4730            if states.iter().any(|s| s.label == label && !s.visible) {
4731                return Err(format!(
4732                    "window '{label}' is not visible — a native screenshot captures the \
4733                     on-screen surface, and a hidden window has none (the OS capture would \
4734                     return stale or another window's pixels). Show it first \
4735                     (window action=manage manage_action=show label={label}), then capture."
4736                ));
4737            }
4738            return Ok(label.to_string());
4739        }
4740        // No label: prefer a visible "main", else the first visible window. NEVER silently
4741        // fall back to a hidden window (the P2 bug) — error instead.
4742        states
4743            .iter()
4744            .find(|s| s.label == "main" && s.visible)
4745            .or_else(|| states.iter().find(|s| s.visible))
4746            .map(|st| st.label.clone())
4747            .ok_or_else(|| {
4748                "no visible window to capture — every window is hidden (or the UI is \
4749                 not responding). Show one first (window action=manage \
4750                 manage_action=show label=<label>), then capture."
4751                    .to_string()
4752            })
4753    }
4754
4755    /// Whether `target` exists, after refusing it if it resolves outside `base`.
4756    ///
4757    /// For a missing target, containment is decided on its deepest EXISTING ancestor, so a
4758    /// path routed through a symlink out of `base` is refused whether or not its final
4759    /// component exists ("missing" vs "outside" would otherwise reveal whether an arbitrary
4760    /// outside path exists).
4761    fn contained_or_missing(
4762        base: &std::path::Path,
4763        target: &std::path::Path,
4764    ) -> Result<bool, String> {
4765        // A missing base holds nothing (and no symlink that could lead out of it).
4766        let Ok(canon_base) = std::fs::canonicalize(base) else {
4767            return Ok(false);
4768        };
4769        let exists = target.exists();
4770        let probe = if exists {
4771            Some(target)
4772        } else {
4773            target.ancestors().skip(1).find(|a| a.exists())
4774        };
4775        if let Some(probe) = probe {
4776            let canonical = std::fs::canonicalize(probe)
4777                .map_err(|e| format!("cannot resolve target path: {e}"))?;
4778            if !canonical.starts_with(&canon_base) {
4779                return Err("path traversal not allowed".to_string());
4780            }
4781        }
4782        Ok(exists)
4783    }
4784
4785    fn matches_glob(name: &str, pattern: &str) -> bool {
4786        if pattern == "*" {
4787            return true;
4788        }
4789        if let Some(suffix) = pattern.strip_prefix("*.") {
4790            return name.ends_with(&format!(".{suffix}"));
4791        }
4792        if let Some(prefix) = pattern.strip_suffix("*") {
4793            return name.starts_with(prefix);
4794        }
4795        name == pattern
4796    }
4797
4798    /// Probe every window's JS bridge and report which are introspectable. A
4799    /// visible window that fails to respond almost always lacks the
4800    /// `victauri:default` capability — Tauri's permission ACL silently blocks
4801    /// the bridge's callback IPC, so eval/dom/animation tools see nothing. This
4802    /// turns that silent dead-end into an actionable, up-front diagnosis.
4803    async fn window_introspectability(&self) -> CallToolResult {
4804        let labels = match self.bridge.try_list_window_labels() {
4805            Ok(labels) => labels,
4806            Err(e) => return tool_error(ui_busy(&e)),
4807        };
4808        let states = match self.bridge.try_get_window_states(None) {
4809            Ok(states) => states,
4810            Err(e) => return tool_error(ui_busy(&e)),
4811        };
4812        let mut report = Vec::with_capacity(labels.len());
4813        let mut blind = 0usize;
4814        for label in &labels {
4815            let visible = states.iter().find(|s| &s.label == label).map(|s| s.visible);
4816            let introspectable = self.probe_bridge(Some(label)).await.is_ok();
4817            if !introspectable {
4818                blind += 1;
4819            }
4820            let note = if introspectable {
4821                "ok — Victauri JS bridge is responding".to_string()
4822            } else if visible == Some(true) {
4823                format!(
4824                    "NOT introspectable although the window is visible — almost certainly missing \
4825                     the Victauri capability. Add \"victauri:default\" to the capability file \
4826                     (src-tauri/capabilities/*.json) whose \"windows\" list includes \"{label}\", \
4827                     then rebuild. Capabilities are baked at compile time, so a rebuild is required."
4828                )
4829            } else {
4830                "NOT introspectable (window is hidden and/or has no bridge) — show the window to \
4831                 confirm, and ensure its capability includes \"victauri:default\", then rebuild."
4832                    .to_string()
4833            };
4834            report.push(serde_json::json!({
4835                "label": label,
4836                "visible": visible,
4837                "introspectable": introspectable,
4838                "note": note,
4839            }));
4840        }
4841        let hint = if blind > 0 {
4842            "Windows with introspectable:false have no working Victauri JS bridge — eval_js, \
4843             dom_snapshot, animation, find_elements, etc. cannot see them. The usual cause is a \
4844             missing \"victauri:default\" capability for that window: Tauri's per-window permission \
4845             ACL silently blocks the bridge's callback IPC. This capability is required per window, \
4846             not just for the main window. (Note: probing a blind window takes ~2s each.)"
4847        } else {
4848            "All windows are introspectable."
4849        };
4850        json_result(&serde_json::json!({
4851            "windows": report,
4852            "introspectable_count": labels.len().saturating_sub(blind),
4853            "blind_count": blind,
4854            "hint": hint,
4855        }))
4856    }
4857
4858    /// Focus `ref_id` before trusted (OS-level) keystrokes and confirm focus LANDED on it —
4859    /// the keys go to whatever holds focus, so an element that exists but did not take focus
4860    /// (not focusable, inert, or a focus handler moved focus on) must stop the input.
4861    async fn focus_for_trusted_input(
4862        &self,
4863        ref_id: &str,
4864        webview_label: Option<&str>,
4865    ) -> Result<(), CallToolResult> {
4866        let raw = self
4867            .eval_with_return(&trusted_focus_probe_js(ref_id), webview_label)
4868            .await
4869            .map_err(|e| {
4870                tool_error(format!(
4871                    "could not focus {ref_id} before sending OS input: {e} — no keys were sent"
4872                ))
4873            })?;
4874        let answer = serde_json::from_str::<serde_json::Value>(&raw).unwrap_or_default();
4875        if answer.get("focused") == Some(&serde_json::Value::Bool(true)) {
4876            return Ok(());
4877        }
4878        let why = if answer.get("found") == Some(&serde_json::Value::Bool(true)) {
4879            "focus did not land on it (not focusable, inert, or a focus handler moved focus \
4880             elsewhere)"
4881        } else {
4882            "ref not found"
4883        };
4884        Err(tool_error_with_hint(
4885            format!(
4886                "ref not found or not focusable: {ref_id}: {why} — no keys were sent (they \
4887                 would have gone to whatever holds focus)"
4888            ),
4889            RecoveryHint::CheckInput,
4890        ))
4891    }
4892
4893    async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
4894        match self.eval_with_return(code, webview_label).await {
4895            Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
4896            Err(e) => tool_error(e),
4897        }
4898    }
4899
4900    /// Run a page ACTION (`interact`, `input`, `inspect`, `route add`) and report a failure the
4901    /// page returns — `{ok: false, error, hint}` or `{error}` — as a tool error carrying the
4902    /// page's message and recovery hint. These used to come back as a SUCCESS result, so a
4903    /// refused click (covered, disabled, ref not found) read as done unless the caller parsed
4904    /// the body (R5B-ISERR1).
4905    async fn eval_page_action(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
4906        match self.eval_with_return(code, webview_label).await {
4907            Ok(result) => match page_action_error(&result) {
4908                Some(message) => tool_error(message),
4909                None => CallToolResult::success(vec![ContentBlock::text(result)]),
4910            },
4911            Err(e) => tool_error(e),
4912        }
4913    }
4914
4915    async fn eval_with_return(
4916        &self,
4917        code: &str,
4918        webview_label: Option<&str>,
4919    ) -> Result<String, String> {
4920        self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
4921            .await
4922    }
4923
4924    /// Atomically reserve a pending-eval slot under a SINGLE lock: reject if the map is
4925    /// already at the concurrency ceiling, otherwise insert. This makes `MAX_PENDING_EVALS`
4926    /// a TRUE hard ceiling — a separate check-then-insert races (concurrent callers all pass
4927    /// a stale check, then each inserts, blowing past the cap). On a saturated map it also
4928    /// fails fast (before any eval is injected) with the real "too many concurrent" cause
4929    /// rather than letting a probe burn its full timeout. The slot is released when the
4930    /// returned guard drops — on every exit, including the caller's future being dropped.
4931    async fn reserve_pending(
4932        &self,
4933        id: &str,
4934        tx: tokio::sync::oneshot::Sender<String>,
4935    ) -> Result<crate::PendingSlot, String> {
4936        let mut pending = self.state.pending_evals.lock().await;
4937        if pending.len() >= MAX_PENDING_EVALS {
4938            return Err(format!(
4939                "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
4940            ));
4941        }
4942        Ok(crate::PendingSlot::insert(
4943            &self.state.pending_evals,
4944            &mut pending,
4945            id.to_string(),
4946            tx,
4947        ))
4948    }
4949
4950    /// Liveness probe. On success, returns the nonce of the page that answered (`None` for a
4951    /// page without the Victauri bridge).
4952    async fn probe_bridge(&self, webview_label: Option<&str>) -> Result<Option<String>, String> {
4953        let id = uuid::Uuid::new_v4().to_string();
4954        let (tx, rx) = tokio::sync::oneshot::channel();
4955        let slot = self.reserve_pending(&id, tx).await?;
4956        slot.bind_window(webview_label);
4957        let probe = crate::js_bridge::eval_probe_script(&id);
4958        if let Err(e) = self.bridge.eval_webview(webview_label, &probe) {
4959            return Err(format!("eval injection failed: {e}"));
4960        }
4961        if let Ok(Ok(raw)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await {
4962            Ok(crate::js_bridge::probe_answer_nonce(&raw))
4963        } else {
4964            let label = webview_label.unwrap_or("default");
4965            Err(format!(
4966                "bridge not responding on window '{label}' — the window may be hidden, \
4967                 missing the victauri capability, or the JS bridge is not loaded (e.g. the page \
4968                 failed to load: a dev-server connection-refused or blank error page has no JS \
4969                 bridge — check the window with the `screenshot` tool, which works regardless)"
4970            ))
4971        }
4972    }
4973
4974    /// Whether window `label` has shown a page other than the one an eval was armed in since
4975    /// ready-signal `seen` — and if so, the error to report. A ready signal carrying the armed
4976    /// nonce is the eval's own page announcing itself late (under load that can take seconds);
4977    /// any other is confirmed by asking the page for its CURRENT nonce, because page script can
4978    /// send a ready signal itself and must not be able to abort the agent's calls with it.
4979    ///
4980    /// Only POSITIVE evidence aborts: the page answering the probe with a nonce other than the
4981    /// armed one (its bridge's nonce is frozen, so it cannot change without a new page — and a
4982    /// page answering with no nonce where one was armed has lost the bridge that armed the
4983    /// eval). A probe that fails (a busy UI thread, a full slot map) proves nothing, so the
4984    /// eval keeps waiting — its own timeout still bounds it — and the signal is re-checked on
4985    /// the next watch tick. Aborting on a failed probe let page script (a forged signal while
4986    /// the UI is busy) cut an agent's call short and invited a retry that ran code twice.
4987    /// With no armed nonce (a page without the Victauri bridge) only a page that now HAS a
4988    /// nonce is evidence of a change.
4989    async fn page_replaced(
4990        &self,
4991        label: Option<&str>,
4992        seen: &mut u64,
4993        armed: Option<&str>,
4994    ) -> Option<String> {
4995        let label = label?;
4996        let load = self.state.page_loads.latest(label)?;
4997        if load.seq <= *seen {
4998            return None;
4999        }
5000        let previously_seen = *seen;
5001        *seen = load.seq;
5002        if armed.is_some() && load.nonce.as_deref() == armed {
5003            return None;
5004        }
5005        match self.probe_bridge(Some(label)).await {
5006            Ok(current) if current.as_deref() != armed => Some(format!(
5007                "window '{label}' loaded a new page (a reload or navigation) while the call was \
5008                 in flight, so no result will arrive. The code may or may not have run before \
5009                 the reload — check the app's state before re-running it."
5010            )),
5011            Ok(_) => None,
5012            Err(e) => {
5013                tracing::debug!(
5014                    window = label,
5015                    "ready signal not confirmed ({e}); the eval keeps waiting"
5016                );
5017                // Unconfirmed: look at this signal again on the next watch tick.
5018                *seen = previously_seen;
5019                None
5020            }
5021        }
5022    }
5023
5024    /// Drain every window into the active recording one last time before `recording stop`,
5025    /// within [`FINAL_FLUSH_BUDGET`] in total. Returns the windows that could not be read in time
5026    /// (their not-yet-drained events are missing from the stopped session).
5027    async fn final_recording_flush(&self) -> Vec<String> {
5028        let Ok(labels) = self.bridge.try_list_window_labels() else {
5029            return vec!["(window list unavailable: UI thread busy)".to_string()];
5030        };
5031        let mut pending = labels.clone();
5032        let flushed = tokio::time::timeout(FINAL_FLUSH_BUDGET, async {
5033            for label in &labels {
5034                if crate::mcp::server::drain_window_into_recording(&self.state, &self.bridge, label)
5035                    .await
5036                    .is_some()
5037                {
5038                    pending.retain(|l| l != label);
5039                }
5040            }
5041        })
5042        .await;
5043        if flushed.is_err() {
5044            tracing::debug!("recording stop: final flush ran out of time");
5045        }
5046        pending
5047    }
5048
5049    async fn eval_with_return_timeout(
5050        &self,
5051        code: &str,
5052        webview_label: Option<&str>,
5053        timeout: std::time::Duration,
5054    ) -> Result<String, String> {
5055        self.eval_outcome(code, webview_label, timeout)
5056            .await
5057            .map_err(|f| f.message)
5058    }
5059
5060    /// Run `code` in the webview and wait for its outcome; a failure says whether the code ran.
5061    #[allow(clippy::too_many_lines)]
5062    async fn eval_outcome(
5063        &self,
5064        code: &str,
5065        webview_label: Option<&str>,
5066        timeout: std::time::Duration,
5067    ) -> Result<String, EvalFailure> {
5068        use EvalFailureKind::{Aborted, NotSent, Page};
5069
5070        // The hard concurrency ceiling is enforced atomically at every reservation
5071        // (`reserve_pending`, used by both the probe and the real eval below) — NOT with a
5072        // separate early check, which races: concurrent callers would all pass a stale
5073        // `len()` read before any of them inserts. The probe is the first reservation, so a
5074        // saturated map is rejected fast (before any eval is injected) with the real "too
5075        // many concurrent" cause.
5076
5077        // Wait for the JS bridge ready signal (sent on bridge init) before
5078        // attempting evals.  For explicitly targeted windows the probe
5079        // mechanism is still used because the ready signal only proves that
5080        // *some* webview's bridge loaded — not necessarily the targeted one.
5081        if !self
5082            .state
5083            .bridge_ready
5084            .load(std::sync::atomic::Ordering::Acquire)
5085        {
5086            let notified = self.state.bridge_notify.notified();
5087            if !self
5088                .state
5089                .bridge_ready
5090                .load(std::sync::atomic::Ordering::Acquire)
5091            {
5092                let _ = tokio::time::timeout(std::time::Duration::from_secs(5), notified).await;
5093            }
5094        }
5095
5096        // Subscribe before anything is sent AND check the current value: `subscribe()` marks it
5097        // seen, so a call started after the app's exit signal would otherwise wait out its whole
5098        // timeout for a change that has already happened.
5099        let mut shutdown = self.state.shutdown_tx.subscribe();
5100        if *shutdown.borrow() {
5101            return Err(EvalFailure::new(
5102                NotSent,
5103                "the app is shutting down, so the call was not sent",
5104            ));
5105        }
5106
5107        // Reserved sentinel key for the default (unlabeled) window — cannot
5108        // collide with a real label.
5109        let label_key =
5110            webview_label.map_or_else(|| "\u{1}__default__".to_string(), str::to_string);
5111
5112        // Ready signals from here on may come from a page that replaced the one this eval runs
5113        // in; an earlier one cannot (the probe below then answers from the new page).
5114        let mut loads_seen = self.state.page_loads.current_seq();
5115
5116        // Liveness probe before EVERY eval — on the DEFAULT window as well as
5117        // labeled ones. The probe is a tiny round-trip that returns in ~ms on a
5118        // healthy bridge and fails fast (~2s) on a dead/hung/reloading one, turning
5119        // a full-timeout hang (e.g. 30s) into an immediate, clear "bridge not
5120        // responding" error. This was the #1 live-4DA friction: a webview that
5121        // reloads mid-session (HMR) made the very next tool call hang the full
5122        // timeout, and the DEFAULT window — the most common target — was never
5123        // probed at all. Probing every call (not once-cached) is what guarantees
5124        // *zero* 30s hangs even across repeated reloads; the healthy-path cost is a
5125        // single sub-millisecond localhost round-trip, negligible against the value
5126        // of never stalling an agent into a CDP fallback. It also reports the nonce of the
5127        // page the eval is armed in, which is what tells a reload from a late ready signal.
5128        let prev_timed_out = self.timed_out_labels.lock().await.remove(&label_key);
5129        let armed_nonce = match self.probe_bridge(webview_label).await {
5130            Ok(nonce) => nonce,
5131            Err(e) => {
5132                return Err(EvalFailure::new(
5133                    NotSent,
5134                    if prev_timed_out {
5135                        format!(
5136                            "{e} (a previous eval on this window also timed out — the webview \
5137                             likely reloaded or the app stopped responding)"
5138                        )
5139                    } else {
5140                        e
5141                    },
5142                ));
5143            }
5144        };
5145
5146        let id = uuid::Uuid::new_v4().to_string();
5147        let (tx, rx) = tokio::sync::oneshot::channel();
5148        let slot = self
5149            .reserve_pending(&id, tx)
5150            .await
5151            .map_err(|e| EvalFailure::new(NotSent, e))?;
5152        // Only the target window may answer (a `None` label is narrowed once it has resolved).
5153        slot.bind_window(webview_label);
5154
5155        // Auto-prepend `return` so bare expressions produce a value — but ONLY
5156        // for single expressions. Multi-statement blocks (or code containing an
5157        // explicit `return`) are used as-is. Prepending `return` to a statement
5158        // block like `foo(); return bar()` would parse as `return foo();` and
5159        // silently discard everything after the first statement (issue: core
5160        // primitive returned wrong/undefined values for "do X, then return Y").
5161        // Prepend to the code with its LEADING comments stripped: `return // note\nexpr`
5162        // parses (ASI) as `return;` and silently yields undefined.
5163        let body = strip_leading_js_comments(code.trim());
5164        let code = if should_prepend_return(body) {
5165            format!("return {body}")
5166        } else {
5167            code.trim().to_string()
5168        };
5169
5170        // Fail fast on a SYNTAX error instead of hanging for the full timeout (audit /
5171        // red-team "malformed eval consumes the full 30s"). The user code is inlined into
5172        // the wrapper; if it has a parse error the WHOLE script fails to parse, so its callback
5173        // never fires. We cannot wrap the code in `new Function`/`AsyncFunction` to surface the
5174        // SyntaxError, because dynamic code generation is gated by the same `unsafe-eval` CSP
5175        // that blocks `eval()` — which is exactly why the bridge uses an inline async-IIFE in
5176        // the first place. Instead a check script (which always parses) is delivered right
5177        // AFTER the wrapper, to the same window: webview evals run in order, and a wrapper that
5178        // parsed has already marked itself begun, so "not begun" means it did not parse. There
5179        // is no timer to race: the check used to be a 750ms watchdog armed BEFORE the code, and
5180        // a busy main thread delaying the code past it reported a parse error for code that
5181        // then ran (an `invoke_command` retried on that ran twice).
5182        // The settle logic lives in the bridge's closure-private state (see `_evalBegin` /
5183        // `_evalCheck` / `_evalSettle` in js_bridge.rs): the page can neither enumerate pending
5184        // eval ids nor suppress results, each outcome is delivered at most once, and
5185        // serialization uses a `JSON.stringify` captured before any page script ran.
5186        let inject = crate::js_bridge::eval_wrapper_script(&id, &code);
5187        let target = self
5188            .bridge
5189            .eval_webview_resolved(webview_label, &inject)
5190            .map_err(|e| EvalFailure::new(NotSent, format!("eval injection failed: {e}")))?;
5191        let deliver_to = if target.is_empty() {
5192            webview_label
5193        } else {
5194            slot.bind_window(Some(target.as_str()));
5195            Some(target.as_str())
5196        };
5197        let check = crate::js_bridge::eval_check_script(&id, armed_nonce.as_deref());
5198        // The check only runs when the page reported a nonce (see `eval_check_script`) and the
5199        // script reached it; only then can a timeout rule out a parse error.
5200        let parse_check_armed = match self.bridge.eval_webview(deliver_to, &check) {
5201            Ok(()) => armed_nonce.is_some(),
5202            Err(e) => {
5203                // Only the fast parse-error report is lost; the code itself was delivered.
5204                tracing::debug!("eval parse check not delivered: {e}");
5205                false
5206            }
5207        };
5208
5209        // While waiting, watch for the ways a call ends with NO callback ever coming: the
5210        // target window was destroyed, it loaded a new page, or the app began shutting down.
5211        // All are the EXPECTED outcome of code that closes its own window, navigates or quits
5212        // the app (e.g. invoking a `quit_app` command) — reporting them after the full timeout
5213        // as "an unresolved promise, an infinite loop…" misled agents into thinking the call
5214        // never ran.
5215        //
5216        // The window check runs in its own task: listing windows is a main-thread round trip
5217        // that can take up to ~2x its 10s dispatch timeout (about 20s) on a busy UI, and must neither stall this
5218        // wait nor push it past its deadline. A listing that FAILS (a busy or wedged UI) is not
5219        // evidence of anything — only a successful listing that lacks the window is.
5220        let watched: Option<String> = (!target.is_empty()).then_some(target);
5221        let deadline = tokio::time::Instant::now() + timeout;
5222        let mut liveness = tokio::time::interval_at(
5223            tokio::time::Instant::now() + EVAL_WINDOW_WATCH_INTERVAL,
5224            EVAL_WINDOW_WATCH_INTERVAL,
5225        );
5226        let mut check: Option<tokio::task::JoinHandle<Result<Vec<String>, String>>> = None;
5227        let armed = armed_nonce.as_deref();
5228        let mut rx = rx;
5229        // A reload that completed while the code was being delivered is already recorded.
5230        let outcome = if let Some(msg) = self
5231            .page_replaced(watched.as_deref(), &mut loads_seen, armed)
5232            .await
5233        {
5234            Err(Some(msg))
5235        } else {
5236            loop {
5237                tokio::select! {
5238                    r = &mut rx => break Ok(r),
5239                    () = self.state.page_loads.changed() => {
5240                        if let Some(msg) =
5241                            self.page_replaced(watched.as_deref(), &mut loads_seen, armed).await
5242                        {
5243                            break Err(Some(msg));
5244                        }
5245                    }
5246                    () = tokio::time::sleep_until(deadline) => break Err(None),
5247                    changed = shutdown.changed() => {
5248                        if changed.is_err() || *shutdown.borrow() {
5249                            break Err(Some(
5250                                "the app began shutting down while the call was in flight, so \
5251                                 no result will arrive. If the code/command quits or restarts the \
5252                                 app, this is the expected outcome and it most likely ran — do \
5253                                 not re-run it blindly."
5254                                    .to_string(),
5255                            ));
5256                        }
5257                    }
5258                    _ = liveness.tick(), if watched.is_some() && check.is_none() => {
5259                        // Backstop for a ready signal that arrived between select rounds.
5260                        if let Some(msg) =
5261                            self.page_replaced(watched.as_deref(), &mut loads_seen, armed).await
5262                        {
5263                            break Err(Some(msg));
5264                        }
5265                        let bridge = Arc::clone(&self.bridge);
5266                        check = Some(tokio::spawn(async move { bridge.try_list_window_labels() }));
5267                    }
5268                    listed = async { check.as_mut().expect("guarded by precondition").await },
5269                        if check.is_some() =>
5270                    {
5271                        check = None;
5272                        if let (Ok(Ok(labels)), Some(label)) = (listed, watched.as_deref())
5273                            && !labels.iter().any(|l| l == label)
5274                        {
5275                            break Err(Some(format!(
5276                                "window '{label}' was closed while the call was in flight, so no \
5277                                 result will arrive. If the code/command closes this window (or \
5278                                 quits the app), this is the expected outcome and it most likely \
5279                                 ran — do not re-run it blindly."
5280                            )));
5281                        }
5282                    }
5283                }
5284            }
5285        };
5286        if let Some(pending_check) = check {
5287            pending_check.abort();
5288        }
5289        let early = match outcome {
5290            Ok(r) => Ok(r),
5291            // The result may have landed while the page change was being confirmed.
5292            Err(Some(msg)) => match rx.try_recv() {
5293                Ok(raw) => Ok(Ok(raw)),
5294                Err(_) => return Err(EvalFailure::new(Aborted, msg)),
5295            },
5296            Err(None) => Err(()),
5297        };
5298        match early {
5299            Ok(Ok(raw)) => {
5300                self.check_bridge_version_once();
5301                if raw.len() > MAX_EVAL_RESULT_LEN {
5302                    return Err(EvalFailure::new(
5303                        Page,
5304                        format!(
5305                            "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
5306                            raw.len()
5307                        ),
5308                    ));
5309                }
5310                unwrap_eval_envelope(raw)
5311            }
5312            Ok(Err(_)) => Err(EvalFailure::new(Aborted, "eval callback channel closed")),
5313            Err(()) => {
5314                // Mark this window so the NEXT eval does a fast liveness probe —
5315                // if the bridge is gone (reloaded/crashed) the next call fails in
5316                // ~2s instead of blocking the full timeout again.
5317                self.timed_out_labels.lock().await.insert(label_key.clone());
5318                let (began, parse_note) = if parse_check_armed {
5319                    (
5320                        "the code began executing but never resolved",
5321                        "(A syntax/parse error is reported immediately, so this is NOT a parse \
5322                         error.) Common causes",
5323                    )
5324                } else {
5325                    (
5326                        "no result arrived",
5327                        "(The fast syntax-error check could not run for this call, so a parse \
5328                         error could not be ruled out — check the code's syntax.) Other causes",
5329                    )
5330                };
5331                Err(EvalFailure::new(
5332                    Aborted,
5333                    format!(
5334                        "eval timed out after {} — {began}. {parse_note}: an unresolved \
5335                         promise, an infinite loop, an `await` on something that never settles, \
5336                         or the webview reloaded / the app stopped responding mid-eval. If the \
5337                         app may have navigated or crashed, retry (the next call fails fast if \
5338                         the bridge is gone).",
5339                        format_timeout(timeout)
5340                    ),
5341                ))
5342            }
5343        }
5344    }
5345
5346    #[cfg(feature = "sqlite")]
5347    async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
5348        let roots = self.db_roots();
5349
5350        let path = if let Some(p) = db_path {
5351            Self::resolve_existing_db_path(&roots, p)?
5352        } else {
5353            // Configured db_search_paths are EXCLUSIVE when set (don't fall back to the
5354            // OS app dirs that hold WebView internals); WebView/engine internal stores are
5355            // excluded and the largest real candidate wins (audit / red-team "wrong DB").
5356            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
5357                roots.clone()
5358            } else {
5359                self.state.db_search_paths.clone()
5360            };
5361            crate::database::select_app_database(&select_dirs)?
5362        };
5363        let path_str = path
5364            .to_str()
5365            .ok_or_else(|| "invalid path encoding".to_string())?
5366            .to_string();
5367
5368        bounded::run_blocking_bounded(
5369            Some(&bounded::DB_SLOTS),
5370            "db health check",
5371            crate::database::DB_HEALTH_META_BUDGET
5372                + DB_HEALTH_COUNT_BUDGET
5373                + DB_HEALTH_CHECK_BUDGET
5374                + bounded::BLOCKING_DEADLINE_SLACK,
5375            move || {
5376                crate::database::db_health_report(
5377                    &path_str,
5378                    DB_HEALTH_COUNT_BUDGET,
5379                    DB_HEALTH_CHECK_BUDGET,
5380                )
5381            },
5382        )
5383        .await
5384    }
5385
5386    fn check_bridge_version_once(&self) {
5387        if self.bridge_checked.swap(true, Ordering::Relaxed) {
5388            return;
5389        }
5390        let handler = self.clone();
5391        tokio::spawn(async move {
5392            match handler
5393                .eval_with_return_timeout(
5394                    "window.__VICTAURI__?.version",
5395                    None,
5396                    std::time::Duration::from_secs(5),
5397                )
5398                .await
5399            {
5400                Ok(v) => {
5401                    let v = v.trim_matches('"');
5402                    if v == BRIDGE_VERSION {
5403                        tracing::debug!("Bridge version verified: {v}");
5404                    } else {
5405                        tracing::warn!(
5406                            "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
5407                        );
5408                    }
5409                }
5410                Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
5411            }
5412        });
5413    }
5414}
5415
5416const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
5417It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
5418(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
5419(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
5420\n\nBACKEND tools (direct Rust access, no webview needed): \
5421'app_info' (app config, directory paths, discovered databases, process info), \
5422'list_app_dir' (browse app data/config/log directories), \
5423'read_app_file' (read files from app directories), \
5424'query_db' (read-only SQLite queries with auto-discovery). \
5425\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
5426'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
5427capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
5428Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
5429capability/security auditing, database diagnostics, plugin state, child process enumeration, \
5430task tracking, and automatic Tauri event bus monitoring. \
5431'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
5432drops, and response corruption into Tauri commands at the Rust layer. \
5433'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
5434activity across IPC + DOM + console + network + window events into a coherent narrative. \
5435\n\nWEBVIEW tools: \
5436'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
5437'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
5438'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
5439\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
5440\n\nCOMPOUND tools with an 'action' parameter: \
5441'window' (get_state, list, manage, resize, move_to, set_title), \
5442'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
5443set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
5444get_events, events_between, get_replay, export, import, replay), \
5445'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
5446\n\nOTHER: verify_state, wait_for (incl. 'expression'/'event' conditions to await \
5447async backend work to true completion), assert_semantic, resolve_command, \
5448app_state (app-defined backend state probes), \
5449get_memory_stats, get_plugin_info, get_diagnostics.";
5450
5451impl ServerHandler for VictauriMcpHandler {
5452    fn get_info(&self) -> InitializeResult {
5453        // NOTE: we advertise `resources` (read) but NOT `resources.subscribe`. A real
5454        // server-initiated `notifications/resources/updated` push was never implemented
5455        // (subscribe/unsubscribe only record intent in memory; nothing emits updates), and
5456        // the default stateless transport has no SSE channel to push over anyway. Advertising
5457        // a subscribe capability we cannot honour misleads clients — read resources on demand.
5458        InitializeResult::new(
5459            ServerCapabilities::builder()
5460                .enable_tools()
5461                .enable_resources()
5462                .build(),
5463        )
5464        .with_instructions(SERVER_INSTRUCTIONS)
5465    }
5466
5467    async fn list_tools(
5468        &self,
5469        _request: Option<PaginatedRequestParams>,
5470        _context: RequestContext<RoleServer>,
5471    ) -> Result<ListToolsResult, ErrorData> {
5472        let all_tools = Self::tool_router().list_all();
5473        let filtered: Vec<Tool> = all_tools
5474            .into_iter()
5475            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
5476            .collect();
5477        // SEP-2549 cache hints: the tool list is fixed for the process lifetime (the
5478        // privacy config that filters it is set at plugin init), so clients may cache
5479        // it. `Private` because the list depends on this instance's privacy profile.
5480        // Legacy (< 2026-07-28) peers never see these fields — rmcp strips them.
5481        let mut result = ListToolsResult::with_all_items(filtered);
5482        result.ttl_ms = Some(LIST_RESULT_TTL_MS);
5483        result.cache_scope = Some(CacheScope::Private);
5484        Ok(result)
5485    }
5486
5487    async fn call_tool(
5488        &self,
5489        request: CallToolRequestParams,
5490        context: RequestContext<RoleServer>,
5491    ) -> Result<CallToolResponse, ErrorData> {
5492        let tool_name: String = request.name.as_ref().to_owned();
5493        // Centralized authorization: gate on the canonical `tool.action` capability
5494        // resolved from the call arguments, matching the REST path in `execute_tool`.
5495        let args_value = serde_json::Value::Object(request.arguments.clone().unwrap_or_default());
5496        // A disabled tool reports "disabled" whatever its arguments look like.
5497        if self.state.privacy.disabled_tools.contains(&tool_name) {
5498            return Ok(tool_disabled(&tool_name).into());
5499        }
5500        let capability = authz::resolve_capability(&tool_name, &args_value)
5501            .map_err(|msg| ErrorData::invalid_params(msg, None))?;
5502        if !self.state.privacy.is_call_allowed(&tool_name, &capability) {
5503            tracing::debug!(tool = %tool_name, capability = %capability, "tool call blocked by privacy config");
5504            return Ok(tool_disabled(&capability).into());
5505        }
5506        self.state
5507            .tool_invocations
5508            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5509        let start = std::time::Instant::now();
5510        tracing::debug!(tool = %tool_name, "tool invocation started");
5511        let ctx = ToolCallContext::new(self, request, context);
5512        // Same panic boundary as the REST path: without it a panicking handler left the MCP
5513        // request unanswered until the client's own timeout.
5514        let response = bounded::CatchUnwind::new(Self::tool_router().call(ctx))
5515            .await
5516            .unwrap_or_else(|panic| {
5517                tracing::error!(tool = %tool_name, "tool handler panicked: {panic}");
5518                Ok(tool_panicked(&tool_name, &panic).into())
5519            });
5520        let elapsed = start.elapsed();
5521        tracing::debug!(
5522            tool = %tool_name,
5523            elapsed_ms = elapsed.as_millis() as u64,
5524            is_error = response.as_ref().map_or(true, |r| match r {
5525                CallToolResponse::Complete(r) => r.is_error.unwrap_or(false),
5526                _ => false,
5527            }),
5528            "tool invocation completed"
5529        );
5530
5531        // Centralized output redaction: apply to all text content so no
5532        // individual tool can accidentally leak secrets. Victauri tools always
5533        // complete in one round trip, so only the `Complete` variant carries
5534        // output; MRTR intermediates (input-required / task) pass through.
5535        if self.state.privacy.redaction_enabled {
5536            response.map(|resp| match resp {
5537                CallToolResponse::Complete(mut r) => {
5538                    for item in &mut r.content {
5539                        if let ContentBlock::Text(tc) = item {
5540                            tc.text = self.state.privacy.redact_output(&tc.text);
5541                        }
5542                    }
5543                    CallToolResponse::Complete(r)
5544                }
5545                other => other,
5546            })
5547        } else {
5548            response
5549        }
5550    }
5551
5552    fn get_tool(&self, name: &str) -> Option<Tool> {
5553        if !self.state.privacy.is_tool_enabled(name) {
5554            return None;
5555        }
5556        Self::tool_router().get(name).cloned()
5557    }
5558
5559    async fn list_resources(
5560        &self,
5561        _request: Option<PaginatedRequestParams>,
5562        _context: RequestContext<RoleServer>,
5563    ) -> Result<ListResourcesResult, ErrorData> {
5564        // SEP-2549 cache hints: the resource *list* (not the contents) is static for
5565        // the process lifetime, so clients may cache it (same rationale as list_tools).
5566        let mut result = ListResourcesResult::with_all_items(vec![
5567            Resource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
5568                .with_description(
5569                    "Live IPC call log — all commands invoked between frontend and backend",
5570                )
5571                .with_mime_type("application/json"),
5572            Resource::new(RESOURCE_URI_WINDOWS, "windows")
5573                .with_description(
5574                    "Current state of all Tauri windows — position, size, visibility, focus",
5575                )
5576                .with_mime_type("application/json"),
5577            Resource::new(RESOURCE_URI_STATE, "state")
5578                .with_description(
5579                    "Victauri plugin state — event count, registered commands, memory stats",
5580                )
5581                .with_mime_type("application/json"),
5582        ]);
5583        result.ttl_ms = Some(LIST_RESULT_TTL_MS);
5584        result.cache_scope = Some(CacheScope::Private);
5585        Ok(result)
5586    }
5587
5588    async fn read_resource(
5589        &self,
5590        request: ReadResourceRequestParams,
5591        _context: RequestContext<RoleServer>,
5592    ) -> Result<ReadResourceResponse, ErrorData> {
5593        let uri = &request.uri;
5594        // Resources bypass the tool dispatcher, so they must apply the same privacy
5595        // gate themselves (audit B1): a strict profile that blocks log/window reads
5596        // as tools must not be able to read the same data via a resource.
5597        if !resource_allowed(&self.state.privacy, uri.as_str()) {
5598            return Err(ErrorData::invalid_request(
5599                format!("resource {uri} is not permitted by the current privacy configuration"),
5600                None,
5601            ));
5602        }
5603        let json = match uri.as_str() {
5604            RESOURCE_URI_IPC_LOG => {
5605                // Use the body-free, capped projection — NOT the full body-carrying
5606                // getIpcLog(). On a busy app the full log blows the eval result cap, the
5607                // eval fails, and we silently fall back to the Rust event_log (which is
5608                // itself default-window-drained) — serving a subset that looks complete.
5609                // trimmed_log_js bounds entries + truncates oversized fields so the
5610                // resource stays correct under load. (Matches the `logs ipc` tool.)
5611                let code = trimmed_log_js(
5612                    &format!("window.__VICTAURI__?.getIpcLog({DEFAULT_LOG_LIMIT})"),
5613                    DEFAULT_LOG_LIMIT,
5614                );
5615                if let Ok(json) = self.eval_with_return(&code, None).await {
5616                    json
5617                } else {
5618                    let calls = self.state.event_log.ipc_calls();
5619                    serde_json::to_string_pretty(&calls)
5620                        .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
5621                }
5622            }
5623            RESOURCE_URI_WINDOWS => {
5624                let states = self
5625                    .bridge
5626                    .try_get_window_states(None)
5627                    .map_err(|e| ErrorData::internal_error(ui_busy(&e), None))?;
5628                serde_json::to_string_pretty(&states)
5629                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
5630            }
5631            RESOURCE_URI_STATE => {
5632                let state_json = serde_json::json!({
5633                    "events_captured": self.state.event_log.len(),
5634                    "commands_registered": self.state.registry.count(),
5635                    "memory": crate::memory::current_stats(),
5636                    "port": self.state.port.load(Ordering::Relaxed),
5637                });
5638                serde_json::to_string_pretty(&state_json)
5639                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
5640            }
5641            _ => {
5642                return Err(ErrorData::resource_not_found(
5643                    format!("unknown resource: {uri}"),
5644                    None,
5645                ));
5646            }
5647        };
5648
5649        let json = if self.state.privacy.redaction_enabled {
5650            self.state.privacy.redact_output(&json)
5651        } else {
5652            json
5653        };
5654
5655        Ok(ReadResourceResult::new(vec![ResourceContents::text(json, uri)]).into())
5656    }
5657
5658    // `resources/subscribe` is legacy-protocol-only under MCP 2026-07-28 (replaced by
5659    // `subscriptions/listen`). Victauri intentionally keeps the legacy handlers: the
5660    // subscribe capability is not advertised (see `get_info`), no update push exists,
5661    // and legacy clients that call anyway get the same recorded-intent behavior as
5662    // before. `allow(deprecated)` because rmcp 3.x marks the trait methods deprecated.
5663    #[allow(deprecated)]
5664    async fn subscribe(
5665        &self,
5666        request: SubscribeRequestParams,
5667        _context: RequestContext<RoleServer>,
5668    ) -> Result<(), ErrorData> {
5669        let uri = &request.uri;
5670        // Same privacy gate as read_resource (audit B1) — don't let a blocked
5671        // resource be subscribed to for push updates.
5672        if !resource_allowed(&self.state.privacy, uri.as_str()) {
5673            return Err(ErrorData::invalid_request(
5674                format!("resource {uri} is not permitted by the current privacy configuration"),
5675                None,
5676            ));
5677        }
5678        match uri.as_str() {
5679            RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
5680                self.subscriptions.lock().await.insert(uri.clone());
5681                tracing::info!("Client subscribed to resource: {uri}");
5682                Ok(())
5683            }
5684            _ => Err(ErrorData::resource_not_found(
5685                format!("unknown resource: {uri}"),
5686                None,
5687            )),
5688        }
5689    }
5690
5691    #[allow(deprecated)]
5692    async fn unsubscribe(
5693        &self,
5694        request: UnsubscribeRequestParams,
5695        _context: RequestContext<RoleServer>,
5696    ) -> Result<(), ErrorData> {
5697        self.subscriptions.lock().await.remove(&request.uri);
5698        tracing::info!("Client unsubscribed from resource: {}", request.uri);
5699        Ok(())
5700    }
5701}
5702
5703/// JS `function trimField(v)` (with its `MB` bound): a string over [`MAX_LOG_FIELD_BYTES`]
5704/// UTF-16 units is cut with a marker, an object whose JSON is larger becomes a size marker.
5705/// The cut never falls between the halves of a surrogate pair — a lone surrogate serializes
5706/// to JSON that `serde_json` rejects, which failed the whole log read.
5707fn trim_field_js() -> String {
5708    let mb = MAX_LOG_FIELD_BYTES;
5709    format!(
5710        r"var MB = {mb};
5711            function trimField(v) {{
5712                if (typeof v === 'string') {{
5713                    if (v.length <= MB) return v;
5714                    var end = MB, c = v.charCodeAt(end - 1);
5715                    if (c >= 0xD800 && c <= 0xDBFF) end--;
5716                    return v.slice(0, end) + '…[+' + (v.length - end) + ' bytes truncated]';
5717                }}
5718                if (v && typeof v === 'object') {{
5719                    var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }}
5720                    if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }}
5721                }}
5722                return v;
5723            }}"
5724    )
5725}
5726
5727/// JS for `check_ipc_integrity`. Classifies the calls from the body-free IPC view and fetches
5728/// full entries (args + result) only for the <= 20 stale and <= 20 errored calls it lists:
5729/// deep-copying every retained body just to count statuses froze the UI thread (R5-JS1).
5730#[doc(hidden)]
5731#[must_use]
5732pub fn ipc_integrity_js(threshold_ms: i64) -> String {
5733    format!(
5734        r"return (function() {{
5735                var V = window.__VICTAURI__;
5736                var log = V?.getIpcLog(0, {{ bodies: false }}) || [];
5737                var now = Date.now();
5738                var threshold = {threshold_ms};
5739                var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
5740                var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
5741                var errored = log.filter(function(c) {{ return c.status === 'error'; }});
5742                var netCount = (V?.getNetworkLog(null, 0, {{ bodies: false }}) || []).length;
5743                var warning = null;
5744                if (log.length === 0 && netCount > 5) {{
5745                    warning = 'Zero IPC calls captured but ' + netCount + ' network requests observed. IPC capture may not be working — verify the app uses Tauri IPC via fetch to ipc.localhost.';
5746                }}
5747                function withBodies(list) {{
5748                    list = list.slice(0, 20);
5749                    if (!list.length) return list;
5750                    var got = V.getIpcLog(0, {{ ids: list.map(function(c) {{ return c.id; }}) }}) || [];
5751                    var byId = {{}};
5752                    for (var i = 0; i < got.length; i++) byId[got[i].id] = got[i];
5753                    return list.map(function(c) {{ return byId[c.id] || c; }});
5754                }}
5755                // INTEGRITY = round-trip soundness: no stuck/stale (never-returned) calls.
5756                // A command that completed with an Err is a HEALTHY round-trip (it returned)
5757                // — every real app exercises error paths, so counting those as 'unhealthy'
5758                // would cry wolf. The error_count/errored_calls surface them for visibility,
5759                // but only stale calls flip `healthy`.
5760                return {{
5761                    healthy: stale.length === 0,
5762                    total_calls: log.length,
5763                    pending_count: pending.length,
5764                    stale_count: stale.length,
5765                    error_count: errored.length,
5766                    stale_calls: withBodies(stale),
5767                    errored_calls: withBodies(errored),
5768                    warning: warning
5769                }};
5770            }})()"
5771    )
5772}
5773
5774/// JS for `logs slow_ipc`: ranks the calls from the body-free IPC view, then fetches full
5775/// (field-trimmed) entries only for the `limit` slowest it returns (R5-JS1).
5776#[doc(hidden)]
5777#[must_use]
5778pub fn slow_ipc_js(threshold_ms: u64, limit: usize) -> String {
5779    let trim_field = trim_field_js();
5780    format!(
5781        r"return (function() {{
5782                {trim_field}
5783                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; }}
5784                var V = window.__VICTAURI__;
5785                var log = V?.getIpcLog(0, {{ bodies: false }}) || [];
5786                var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold_ms}; }});
5787                slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
5788                var top = slow.slice(0, {limit});
5789                if (top.length) {{
5790                    var got = V.getIpcLog(0, {{ ids: top.map(function(c) {{ return c.id; }}) }}) || [];
5791                    var byId = {{}};
5792                    for (var i = 0; i < got.length; i++) byId[got[i].id] = got[i];
5793                    top = top.map(function(c) {{ return byId[c.id] || c; }});
5794                }}
5795                return {{ threshold_ms: {threshold_ms}, count: top.length, calls: top.map(trimEntry) }};
5796            }})()",
5797    )
5798}
5799
5800/// Build a JS expression that takes an array of log entries (`source_expr`),
5801/// keeps at most `limit` of the most recent, and truncates any per-entry field
5802/// larger than [`MAX_LOG_FIELD_BYTES`]. This keeps IPC/network log results under
5803/// the eval size cap on busy apps where individual entries carry large bodies.
5804///
5805/// The returned code is a complete `return (...)` statement.
5806#[doc(hidden)]
5807#[must_use]
5808pub fn trimmed_log_js(source_expr: &str, limit: usize) -> String {
5809    let trim_field = trim_field_js();
5810    format!(
5811        r"return (function() {{
5812            {trim_field}
5813            function trimEntry(e) {{
5814                if (e == null || typeof e !== 'object') return e;
5815                var out = Array.isArray(e) ? [] : {{}};
5816                for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) out[k] = trimField(e[k]); }}
5817                return out;
5818            }}
5819            var arr = {source_expr} || [];
5820            if (arr.length > {limit}) arr = arr.slice(arr.length - {limit}); // not slice(-0): all
5821            return arr.map(trimEntry);
5822        }})()"
5823    )
5824}
5825
5826/// Wall-clock epoch milliseconds: the floor a new recording epoch's drain reads from. Taken
5827/// BEFORE the recording starts, so nothing logged after the start falls below it. (An imported
5828/// session's old start time used to pull in the page's whole history.)
5829fn now_ms() -> f64 {
5830    #[allow(clippy::cast_precision_loss)]
5831    let ms = chrono::Utc::now().timestamp_millis() as f64;
5832    ms
5833}
5834
5835/// Unwrap the `{"__victauri_ok": <val>, "__victauri_type": <t>}` (or
5836/// `{"__victauri_err": <msg>}`) envelope produced by the eval bridge into the
5837/// value/error string returned to callers.
5838///
5839/// Parsing uses `serde_json`'s default recursion limit (it is intentionally NOT
5840/// disabled — an unbounded recursive parse of a pathologically deep result
5841/// overflows the worker thread stack and crashes the host). When the parse
5842/// fails because the value is too deeply nested, the envelope is stripped by
5843/// string slicing (no recursion) so the actual value is still returned rather
5844/// than leaking the raw envelope string.
5845fn unwrap_eval_envelope(raw: String) -> Result<String, EvalFailure> {
5846    // Page JSON: sanitize lone surrogates first, or one truncated emoji in a result sends it
5847    // down the raw-string fallback below (and every consumer's own parse of it fails too).
5848    if let Ok(envelope) = page_json::parse_page_json::<serde_json::Value>(&raw) {
5849        if let Some(why) = envelope.get("__victauri_not_run") {
5850            return Err(EvalFailure::new(
5851                EvalFailureKind::NotSent,
5852                format!(
5853                    "{PARSE_ERROR_PREFIX} {}",
5854                    why.as_str().unwrap_or("the code did not begin executing")
5855                ),
5856            ));
5857        }
5858        if let Some(err) = envelope.get("__victauri_err") {
5859            return Err(EvalFailure::new(
5860                EvalFailureKind::Page,
5861                format!(
5862                    "JavaScript error: {}",
5863                    err.as_str().unwrap_or("unknown error")
5864                ),
5865            ));
5866        }
5867        if let Some(why) = envelope.get("__victauri_unserializable") {
5868            return Err(EvalFailure::new(
5869                EvalFailureKind::Page,
5870                format!(
5871                    "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.",
5872                    why.as_str().unwrap_or("unknown reason")
5873                ),
5874            ));
5875        }
5876        if envelope.get("__victauri_ok").is_some() {
5877            let js_type = envelope
5878                .get("__victauri_type")
5879                .and_then(|t| t.as_str())
5880                .unwrap_or("value");
5881            return match js_type {
5882                "undefined" => Ok("undefined".to_string()),
5883                "null" => Ok("null".to_string()),
5884                _ => Ok(serde_json::to_string(&envelope["__victauri_ok"])
5885                    .unwrap_or_else(|_| "null".to_string())),
5886            };
5887        }
5888    }
5889    // Fallback for results too deeply nested for the recursion-limited parser. `rfind` is
5890    // correct here: the wrapper appends `,"__victauri_type":"<type>"}` AFTER the entire payload,
5891    // so the real delimiter is structurally the LAST occurrence — a nested object key of the same
5892    // name appears earlier, and inside a string payload the quotes are escaped (`\"`), so neither
5893    // can be the last match. A hostile page therefore can't shift the slice boundary.
5894    if let Some(after) = raw.strip_prefix(r#"{"__victauri_ok":"#)
5895        && let Some(idx) = after.rfind(r#","__victauri_type":"#)
5896    {
5897        return Ok(after[..idx].to_string());
5898    }
5899    if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) {
5900        let msg = after.trim_end_matches('}').trim_matches('"');
5901        return Err(EvalFailure::new(
5902            EvalFailureKind::Page,
5903            format!("JavaScript error: {msg}"),
5904        ));
5905    }
5906    Ok(raw)
5907}
5908
5909/// The eval code for a caller-supplied EXPRESSION (`verify_state`, `assert_semantic`,
5910/// `wait_for` expression): code that returns the expression's value.
5911///
5912/// These used to be wrapped as `return (<expr>)`, so a trailing `;` or a trailing `// comment`
5913/// (which swallowed the closing paren) turned a valid expression into a parse error. A single
5914/// expression is now handed to the eval engine as-is: its string/comment-aware `return`
5915/// auto-prepend already accepts a trailing `;`, trailing comments and multi-line expressions,
5916/// and the wrapper ends the code with a newline. Anything the engine would NOT wrap (an object
5917/// literal `{a: 1}.a`, which reads as a block) keeps the parenthesized form, with trailing `;`s
5918/// dropped and the expression on lines of its own so a trailing line comment cannot swallow the
5919/// closing paren. Multi-statement code stays a syntax error (these tools take an expression).
5920fn expression_eval_code(expr: &str) -> String {
5921    if should_prepend_return(expr) {
5922        return expr.to_string();
5923    }
5924    let body = expr.trim_end_matches(|c: char| c == ';' || is_js_space(c));
5925    format!("return (\n{body}\n);")
5926}
5927
5928/// Parse the value of an expression evaluated by [`expression_eval_code`]. `undefined` (which
5929/// the engine reports as the bare text `undefined`) is read as `null`: JSON has no `undefined`,
5930/// and `null` is what JavaScript's `x == null` groups it with — so `exists` is false and `falsy`
5931/// is true for it instead of the call failing as "not valid JSON".
5932fn parse_expression_value(raw: &str) -> Result<serde_json::Value, serde_json::Error> {
5933    if raw == "undefined" {
5934        return Ok(serde_json::Value::Null);
5935    }
5936    serde_json::from_str(raw)
5937}
5938
5939/// The failure a page action reported, as tool-error text: `{ok: false, …}`, or an object with
5940/// a top-level `error` string and no `ok: true`. Anything else (arrays with per-item errors, an
5941/// audit whose findings contain an `error` field) is a result. The page's recovery hint is kept
5942/// in the same `[hint: …]` form every tool error uses; it is page-controlled, so only a plain
5943/// `UPPER_SNAKE` word is carried over.
5944fn page_action_error(result: &str) -> Option<String> {
5945    let value: serde_json::Value = page_json::parse_page_json(result).ok()?;
5946    let obj = value.as_object()?;
5947    let ok = obj.get("ok").and_then(serde_json::Value::as_bool);
5948    let error = obj.get("error").and_then(serde_json::Value::as_str);
5949    if ok == Some(true) || (ok.is_none() && error.is_none()) {
5950        return None;
5951    }
5952    let mut message = error.map_or_else(
5953        || format!("the page reported failure: {value}"),
5954        str::to_string,
5955    );
5956    if let Some(hint) = obj.get("hint").and_then(serde_json::Value::as_str)
5957        && !hint.is_empty()
5958        && hint.len() <= 32
5959        && hint.bytes().all(|b| b.is_ascii_uppercase() || b == b'_')
5960    {
5961        message.push_str(&format!("\n\n[hint: {hint}]"));
5962    }
5963    Some(message)
5964}
5965
5966/// Statement keywords where a leading `return` would be a syntax error. Matched as whole words
5967/// (followed by any non-identifier byte — `if\t(`, `const\n`, `function*` — or the end).
5968const STMT_KEYWORDS: &[&str] = &[
5969    "return", "if", "for", "while", "switch", "try", "const", "let", "var", "function", "class",
5970    "throw", "do", "debugger", "with",
5971];
5972
5973/// `code` starts with the whole word `word` (not merely a longer identifier sharing its prefix).
5974fn starts_with_word(code: &str, word: &str) -> bool {
5975    code.starts_with(word)
5976        && code[word.len()..]
5977            .chars()
5978            .next()
5979            .is_none_or(|c| !is_js_ident_char(c))
5980}
5981
5982/// Does `code` begin with a statement (not an expression) — a statement keyword, a block, an
5983/// `async function` declaration, or a label (`outer: for …`)? Prepending `return` to any of
5984/// these is a syntax error or changes its meaning.
5985fn starts_with_statement(code: &str) -> bool {
5986    if code.starts_with('{') || STMT_KEYWORDS.iter().any(|k| starts_with_word(code, k)) {
5987        return true;
5988    }
5989    if starts_with_word(code, "async") && starts_with_word(code[5..].trim_start(), "function") {
5990        return true;
5991    }
5992    // A label: an identifier followed (after optional whitespace) by a single `:`.
5993    let ident_len: usize = code
5994        .chars()
5995        .take_while(|&c| is_js_ident_char(c))
5996        .map(char::len_utf8)
5997        .sum();
5998    ident_len > 0
5999        && !code.as_bytes()[0].is_ascii_digit()
6000        && code[ident_len..].trim_start().starts_with(':')
6001}
6002
6003/// Blocking: open `path`, refuse it unless the OPENED file is a regular file (the handler's
6004/// earlier checks ran on the path, which can be swapped for a FIFO or device before the open),
6005/// then read at most `max_bytes + 1` bytes (audit B7: never the whole file; the `+1` detects
6006/// truncation). Returns the bytes, the file's size and its modification time (Unix seconds).
6007pub(crate) fn read_regular_file(
6008    path: &std::path::Path,
6009    max_bytes: usize,
6010) -> Result<(Vec<u8>, usize, Option<u64>), String> {
6011    use std::io::Read;
6012    let f = std::fs::File::open(path).map_err(|e| e.to_string())?;
6013    let metadata = f.metadata().map_err(|e| e.to_string())?;
6014    if !metadata.is_file() {
6015        return Err("not a regular file".to_string());
6016    }
6017    let modified = metadata.modified().ok().map(|t| {
6018        t.duration_since(std::time::SystemTime::UNIX_EPOCH)
6019            .unwrap_or_default()
6020            .as_secs()
6021    });
6022    let mut buf = Vec::new();
6023    f.take(max_bytes as u64 + 1)
6024        .read_to_end(&mut buf)
6025        .map_err(|e| e.to_string())?;
6026    let size = usize::try_from(metadata.len()).unwrap_or(usize::MAX);
6027    Ok((buf, size, modified))
6028}
6029
6030/// Resolve `.` and `..` components without touching the filesystem.
6031#[cfg(feature = "sqlite")]
6032fn lexically_normalize(path: &std::path::Path) -> std::path::PathBuf {
6033    let mut out = std::path::PathBuf::new();
6034    for component in path.components() {
6035        match component {
6036            std::path::Component::CurDir => {}
6037            std::path::Component::ParentDir => {
6038                out.pop();
6039            }
6040            other => out.push(other),
6041        }
6042    }
6043    out
6044}
6045
6046/// A line ending in one of these continues onto the next line (no ASI).
6047const ASI_CONTINUES_AFTER: &[u8] = b"+-*/%&|^!=<>?:,.([{~";
6048/// A line starting with one of these continues the previous line (no ASI) — including `(`,
6049/// `[` and a template literal, which JavaScript itself treats as a continuation.
6050const ASI_CONTINUES_BEFORE: &[u8] = b".?)]}+-*/%&|^=<>,:([`";
6051
6052/// Length of the JavaScript line terminator at byte `i` — LF, CR, CRLF (2), or U+2028 /
6053/// U+2029 (3) — or 0 when there is none there.
6054fn line_terminator_len(bytes: &[u8], i: usize) -> usize {
6055    match bytes.get(i) {
6056        Some(b'\n') => 1,
6057        Some(b'\r') => 1 + usize::from(bytes.get(i + 1) == Some(&b'\n')),
6058        Some(0xE2)
6059            if bytes.get(i + 1) == Some(&0x80) && matches!(bytes.get(i + 2), Some(0xA8 | 0xA9)) =>
6060        {
6061            3
6062        }
6063        _ => 0,
6064    }
6065}
6066
6067/// Index of the first line terminator at or after byte `from`, if any.
6068fn find_line_terminator(bytes: &[u8], from: usize) -> Option<usize> {
6069    (from..bytes.len()).find(|&i| line_terminator_len(bytes, i) > 0)
6070}
6071
6072/// `code` after the rest of its current line (and that line's terminator); `""` if none.
6073fn after_line(code: &str) -> &str {
6074    let bytes = code.as_bytes();
6075    find_line_terminator(bytes, 0).map_or("", |n| &code[n + line_terminator_len(bytes, n)..])
6076}
6077
6078/// Strip leading whitespace and comments: `//`, `/* */`, and the HTML-like `<!--` / `-->`
6079/// line comments (the code starts a line). Every JavaScript line terminator ends a line
6080/// comment — CR and U+2028/U+2029 as well as LF.
6081fn strip_leading_js_comments(mut code: &str) -> &str {
6082    loop {
6083        code = code.trim_start();
6084        if let Some(rest) = code
6085            .strip_prefix("//")
6086            .or_else(|| code.strip_prefix("<!--"))
6087            .or_else(|| code.strip_prefix("-->"))
6088        {
6089            code = after_line(rest);
6090        } else if let Some(rest) = code.strip_prefix("/*") {
6091            code = rest.find("*/").map_or("", |n| &rest[n + 2..]);
6092        } else {
6093            return code;
6094        }
6095    }
6096}
6097
6098/// What opened a bracket, for [`should_prepend_return`]: a `/` right after the `)` of an
6099/// `if`/`while`/`for`/`with` head starts a regex (the statement body), not a division, and `of`
6100/// is a keyword only directly inside a `for (…)` head.
6101#[derive(PartialEq, Clone, Copy)]
6102enum Bracket {
6103    Plain,
6104    ControlHead,
6105    ForHead,
6106}
6107
6108/// String/template/comment scan state for [`should_prepend_return`].
6109#[derive(PartialEq, Clone, Copy)]
6110enum ScanState {
6111    Code,
6112    SingleQuote,
6113    DoubleQuote,
6114    Template,
6115}
6116
6117/// Decide whether to wrap `code` with a leading `return`.
6118///
6119/// Only a single bare expression should get `return` prepended. Code that is a
6120/// multi-statement block, contains an explicit top-level `return`, or starts
6121/// with a statement keyword is used as-is — prepending `return` to such code
6122/// would execute only the first statement and silently discard the rest.
6123///
6124/// The scan is string/template/comment-aware and only treats a `;` or an
6125/// explicit `return` token as significant when it occurs at bracket depth 0
6126/// outside of any string, template literal, or comment.
6127fn should_prepend_return(code: &str) -> bool {
6128    use ScanState::{Code, DoubleQuote, SingleQuote, Template};
6129
6130    let code = strip_leading_js_comments(code.trim());
6131    if code.is_empty() || starts_with_statement(code) {
6132        return false;
6133    }
6134
6135    let bytes = code.as_bytes();
6136    let mut i = 0;
6137    let mut depth: i32 = 0;
6138    let mut state = ScanState::Code;
6139    // Depths at which a template literal's `${` substitution opened: the matching `}` resumes
6140    // the template (else a backtick inside `${'`'}` was read as the template's end).
6141    let mut template_depths: Vec<i32> = Vec::new();
6142    // Only whitespace since the last line terminator (an HTML-like `-->` comment position).
6143    let mut at_line_start = true;
6144    // Every open bracket (and `${`) and what opened it; the index of the last `)` that closed
6145    // an `if`/`while`/`for`/`with` head.
6146    let mut brackets: Vec<Bracket> = Vec::new();
6147    let mut control_head_closed_at: Option<usize> = None;
6148
6149    // Is there a top-level `return` token starting at byte `i` (word-bounded, and not a
6150    // property name such as `obj.return`)?
6151    let is_return_token = |i: usize| -> bool {
6152        let prev_ok = code[..i]
6153            .chars()
6154            .next_back()
6155            .is_none_or(|c| !is_js_ident_char(c));
6156        prev_ok
6157            && code[i..].starts_with("return")
6158            && code[i + 6..]
6159                .chars()
6160                .next()
6161                .is_none_or(|c| !is_js_ident_char(c))
6162            && !preceded_by_dot(code, i)
6163    };
6164
6165    // The last two significant (non-whitespace, non-comment) bytes seen in code, and where the
6166    // last one is — for the ASI and regex-vs-division decisions.
6167    let mut last_sig: Option<u8> = None;
6168    let mut prev_sig: Option<u8> = None;
6169    let mut last_sig_idx = 0usize;
6170
6171    while i < bytes.len() {
6172        let c = bytes[i];
6173        // LF, CR, CRLF and U+2028 / U+2029 are all JavaScript line terminators.
6174        let terminator = line_terminator_len(bytes, i);
6175        if state == Code && terminator > 0 {
6176            let next_start = i + terminator;
6177            if depth <= 0 && asi_ends_statement(code, next_start, last_sig, prev_sig, last_sig_idx)
6178            {
6179                return false;
6180            }
6181            at_line_start = true;
6182            i = next_start;
6183            continue;
6184        }
6185        match state {
6186            Code => {
6187                // HTML-like comments (Script goal): `<!--` anywhere, and `-->` at the start of
6188                // a line, comment out the rest of the line — up to ANY line terminator.
6189                let line_comment = bytes[i..].starts_with(b"<!--")
6190                    || (at_line_start && bytes[i..].starts_with(b"-->"))
6191                    || bytes[i..].starts_with(b"//");
6192                if line_comment {
6193                    i = find_line_terminator(bytes, i).unwrap_or(bytes.len());
6194                    continue;
6195                }
6196                // A non-ASCII character: JavaScript whitespace (NBSP, BOM, …) is skipped like
6197                // any whitespace; anything else is read as part of an identifier (`énew` is a
6198                // name, not the keyword `new`). Line terminators were handled above.
6199                if c >= 0x80 {
6200                    let ch = code.get(i..).and_then(|rest| rest.chars().next());
6201                    let len = ch.map_or(1, char::len_utf8);
6202                    if !ch.is_some_and(is_js_space) {
6203                        at_line_start = false;
6204                        prev_sig = last_sig;
6205                        last_sig = Some(c);
6206                        last_sig_idx = i + len - 1;
6207                    }
6208                    i += len;
6209                    continue;
6210                }
6211                if !c.is_ascii_whitespace() {
6212                    at_line_start = false;
6213                }
6214                let in_for_head = brackets.last() == Some(&Bracket::ForHead);
6215                match c {
6216                    b'\'' => state = SingleQuote,
6217                    b'"' => state = DoubleQuote,
6218                    b'`' => state = Template,
6219                    b'/' if bytes.get(i + 1) == Some(&b'*') => {
6220                        let body_start = i + 2;
6221                        let end = code[body_start..]
6222                            .find("*/")
6223                            .map_or(bytes.len(), |n| body_start + n);
6224                        i = (end + 2).min(bytes.len());
6225                        // A block comment spanning a line break IS a line break for ASI:
6226                        // `a = 1 /*\n*/ b = 2` is two statements.
6227                        if find_line_terminator(&bytes[..end], body_start).is_some() {
6228                            if depth <= 0
6229                                && asi_ends_statement(code, i, last_sig, prev_sig, last_sig_idx)
6230                            {
6231                                return false;
6232                            }
6233                            at_line_start = true;
6234                        }
6235                        continue;
6236                    }
6237                    // After `}` a `/` is division if the brace closed an object literal, and a
6238                    // regex if it closed a block — undecidable here, so run the code as-is.
6239                    b'/' if last_sig == Some(b'}') => return false,
6240                    b'/' if (last_sig == Some(b')')
6241                        && control_head_closed_at == Some(last_sig_idx))
6242                        || slash_starts_regex(
6243                            code,
6244                            last_sig,
6245                            prev_sig,
6246                            last_sig_idx,
6247                            in_for_head,
6248                        ) =>
6249                    {
6250                        // A regex literal: skip it whole (a quote or newline-like character
6251                        // inside it must not be read as code), then its flags.
6252                        i = skip_regex_literal(bytes, i);
6253                        prev_sig = last_sig;
6254                        last_sig = Some(b')'); // an operand, like a closed group
6255                        last_sig_idx = i.saturating_sub(1);
6256                        continue;
6257                    }
6258                    b'(' | b'[' | b'{' => {
6259                        depth += 1;
6260                        let head = if c == b'(' {
6261                            control_head_kind(code, last_sig, last_sig_idx)
6262                        } else {
6263                            Bracket::Plain
6264                        };
6265                        brackets.push(head);
6266                    }
6267                    b')' | b']' | b'}' => {
6268                        depth -= 1;
6269                        if brackets.pop().is_some_and(|b| b != Bracket::Plain) && c == b')' {
6270                            control_head_closed_at = Some(i);
6271                        }
6272                        if c == b'}' && template_depths.last() == Some(&depth) {
6273                            template_depths.pop();
6274                            state = Template;
6275                        }
6276                    }
6277                    // A top-level `;` with more CODE after it (not just a comment) is a
6278                    // multi-statement block.
6279                    b';' if depth <= 0 && !strip_leading_js_comments(&code[i + 1..]).is_empty() => {
6280                        return false;
6281                    }
6282                    // An explicit top-level `return` token means the code already returns.
6283                    b'r' if depth <= 0 && is_return_token(i) => return false,
6284                    _ => {}
6285                }
6286                if !c.is_ascii_whitespace() {
6287                    prev_sig = last_sig;
6288                    last_sig = Some(c);
6289                    last_sig_idx = i;
6290                }
6291            }
6292            SingleQuote | DoubleQuote | Template => {
6293                let close = match state {
6294                    SingleQuote => b'\'',
6295                    DoubleQuote => b'"',
6296                    _ => b'`',
6297                };
6298                if c == b'\\' {
6299                    i += 1;
6300                } else if state == Template && c == b'$' && bytes.get(i + 1) == Some(&b'{') {
6301                    template_depths.push(depth);
6302                    brackets.push(Bracket::Plain);
6303                    depth += 1;
6304                    state = Code;
6305                    i += 1;
6306                } else if c == close {
6307                    state = Code;
6308                    prev_sig = last_sig;
6309                    last_sig = Some(c);
6310                    last_sig_idx = i;
6311                }
6312            }
6313        }
6314        i += 1;
6315    }
6316
6317    true
6318}
6319
6320/// An identifier byte: ASCII letter/digit/`_`/`$`, or any byte of a non-ASCII character (the
6321/// scanner steps over non-ASCII whitespace itself, so what remains is part of a name).
6322fn is_js_ident(b: u8) -> bool {
6323    b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || b >= 0x80
6324}
6325
6326/// JavaScript whitespace outside ASCII: the Unicode space separators (NBSP, U+2000…), and BOM.
6327fn is_js_space(c: char) -> bool {
6328    c.is_whitespace() || c == '\u{feff}'
6329}
6330
6331/// A character that can be part of an identifier (approximately: non-ASCII letters are not
6332/// told apart from other non-ASCII symbols, which are syntax errors anyway).
6333fn is_js_ident_char(c: char) -> bool {
6334    if c.is_ascii() {
6335        c.is_ascii_alphanumeric() || c == '_' || c == '$'
6336    } else {
6337        !is_js_space(c) && !matches!(c, '\u{2028}' | '\u{2029}')
6338    }
6339}
6340
6341/// Start of the identifier that ends just before byte `end` (a char boundary); `end` if none.
6342fn ident_start_before(code: &str, end: usize) -> usize {
6343    let Some(head) = code.get(..end) else {
6344        return end;
6345    };
6346    let mut start = end;
6347    for (idx, c) in head.char_indices().rev() {
6348        if !is_js_ident_char(c) {
6349            break;
6350        }
6351        start = idx;
6352    }
6353    start
6354}
6355
6356/// What a `(` opens, given the last significant token before it: an `if`/`while`/`with` head,
6357/// a `for` (or `for await`) head, or anything else.
6358fn control_head_kind(code: &str, last_sig: Option<u8>, last_sig_idx: usize) -> Bracket {
6359    if !last_sig.is_some_and(is_js_ident) {
6360        return Bracket::Plain;
6361    }
6362    match js_keyword_ending_at(code, last_sig_idx) {
6363        "if" | "while" | "with" => Bracket::ControlHead,
6364        "for" => Bracket::ForHead,
6365        "await" => {
6366            // `for await (`: the word before `await`.
6367            let before = code[..=last_sig_idx]
6368                .strip_suffix("await")
6369                .unwrap_or("")
6370                .trim_end();
6371            match before.len().checked_sub(1) {
6372                Some(end) if js_keyword_ending_at(code, end) == "for" => Bracket::ForHead,
6373                _ => Bracket::Plain,
6374            }
6375        }
6376        _ => Bracket::Plain,
6377    }
6378}
6379
6380/// Is the token starting at byte `start` a property name (`obj.of`, `a?.return`)? Such a
6381/// word is never a keyword.
6382fn preceded_by_dot(code: &str, start: usize) -> bool {
6383    code[..start].trim_end().ends_with('.')
6384}
6385
6386/// The KEYWORD candidate that ends at byte `end` (inclusive): the identifier there, or `""`
6387/// when `code[end]` is not an identifier byte or the word is a property name after `.`.
6388fn js_keyword_ending_at(code: &str, end: usize) -> &str {
6389    let bytes = code.as_bytes();
6390    if !bytes.get(end).copied().is_some_and(is_js_ident) || !code.is_char_boundary(end + 1) {
6391        return "";
6392    }
6393    let start = ident_start_before(code, end + 1);
6394    if start > end || preceded_by_dot(code, start) {
6395        return "";
6396    }
6397    &code[start..=end]
6398}
6399
6400/// Keywords after which an expression (not a statement end) must follow.
6401///
6402/// NOT listed: `yield`, which is a plain identifier inside the async-arrow wrapper eval code
6403/// runs in (it is a keyword only in generators and strict code), and `of`, which is a keyword
6404/// only inside a `for (… of …)` head — see [`is_expr_keyword`].
6405const EXPR_KEYWORDS: &[&str] = &[
6406    "instanceof",
6407    "in",
6408    "typeof",
6409    "void",
6410    "delete",
6411    "new",
6412    "await",
6413    "return",
6414    "case",
6415    "do",
6416    "else",
6417    "throw",
6418];
6419
6420/// Whether `word` is a keyword after which an operand must follow. `of` is one only directly
6421/// inside a `for (…)` head (`in_for_head`); anywhere else it is an identifier (`f(of / 2)`, or
6422/// `of` then a new line then `foo()`, which is two statements).
6423fn is_expr_keyword(word: &str, in_for_head: bool) -> bool {
6424    (word == "of" && in_for_head) || EXPR_KEYWORDS.contains(&word)
6425}
6426
6427/// Is the `.` at byte `dot` the end of a numeric literal (`1.`) — a complete operand — rather
6428/// than a member access? Only a plain decimal integer (digits and `_`, not itself after a `.`)
6429/// qualifies: `a1.`, `x.`, `0x1.`, `1e3.`, `1n.` and `1.5.` are member accesses. A misread
6430/// only matters in one direction: reading a member access as a complete number merely skips
6431/// the `return` prepend, which is always safe.
6432fn dot_completes_number(code: &str, dot: usize) -> bool {
6433    let bytes = code.as_bytes();
6434    let start = ident_start_before(code, dot);
6435    let word = &bytes[start..dot];
6436    word.first().is_some_and(u8::is_ascii_digit)
6437        && word.iter().all(|b| b.is_ascii_digit() || *b == b'_')
6438        && !(start > 0 && bytes[start - 1] == b'.')
6439}
6440
6441/// A line (or operand) ending in POSTFIX `++`/`--`.
6442fn ends_in_postfix(last_sig: Option<u8>, prev_sig: Option<u8>) -> bool {
6443    matches!(
6444        (prev_sig, last_sig),
6445        (Some(b'+'), Some(b'+')) | (Some(b'-'), Some(b'-'))
6446    )
6447}
6448
6449/// Does a line break just before `next_start` end the statement (JavaScript ASI)? Only
6450/// consulted at bracket depth 0.
6451fn asi_ends_statement(
6452    code: &str,
6453    next_start: usize,
6454    last_sig: Option<u8>,
6455    prev_sig: Option<u8>,
6456    last_sig_idx: usize,
6457) -> bool {
6458    let rest = strip_leading_js_comments(&code[next_start.min(code.len())..]);
6459    let Some(&next) = rest.as_bytes().first() else {
6460        return false; // nothing follows
6461    };
6462    // Restricted production: `a\n++b` is `a; ++b`.
6463    if rest.starts_with("++") || rest.starts_with("--") {
6464        return true;
6465    }
6466    // After POSTFIX `++`/`--` nothing but a binary operator can continue the expression, and
6467    // `i++\n[…]` / `i++\n(…)` are ASI'd into two statements — treat any following line as a
6468    // new statement (not prepending is always safe; prepending would drop that line).
6469    if ends_in_postfix(last_sig, prev_sig) {
6470        return true;
6471    }
6472    // A line ending in `1.` ends in a complete number, not a member access: `1.` then a new
6473    // line then `f()` is two statements.
6474    let number_dot = last_sig == Some(b'.') && dot_completes_number(code, last_sig_idx);
6475    let continues_after = (!number_dot
6476        && last_sig.is_some_and(|p| ASI_CONTINUES_AFTER.contains(&p)))
6477        || is_expr_keyword(js_keyword_ending_at(code, last_sig_idx), false);
6478    !(continues_after || ASI_CONTINUES_BEFORE.contains(&next))
6479}
6480
6481/// Whether a `/` (not starting a comment) begins a regex literal rather than division:
6482/// true where an operand is expected — at the start, after an operator or opening
6483/// punctuation, or after a keyword such as `return`/`typeof`. After a postfix `++`/`--`
6484/// (a complete operand) it is division. (After the `)` of a control-statement head it is a
6485/// regex too; the caller decides that case.)
6486fn slash_starts_regex(
6487    code: &str,
6488    last_sig: Option<u8>,
6489    prev_sig: Option<u8>,
6490    last_sig_idx: usize,
6491    in_for_head: bool,
6492) -> bool {
6493    if ends_in_postfix(last_sig, prev_sig) {
6494        return false;
6495    }
6496    match last_sig {
6497        None => true,
6498        Some(b) if b"(,=:[!&|?{};+-*%<>~^".contains(&b) => true,
6499        Some(b) if is_js_ident(b) => {
6500            is_expr_keyword(js_keyword_ending_at(code, last_sig_idx), in_for_head)
6501        }
6502        Some(_) => false,
6503    }
6504}
6505
6506/// Skip a regex literal starting at the `/` at `start`; returns the index after its flags.
6507fn skip_regex_literal(bytes: &[u8], start: usize) -> usize {
6508    let mut i = start + 1;
6509    let mut in_class = false;
6510    while i < bytes.len() {
6511        match bytes[i] {
6512            b'\\' => i += 1,
6513            b'[' => in_class = true,
6514            b']' => in_class = false,
6515            b'/' if !in_class => break,
6516            // Unterminated at a line terminator (any of them): let it be scanned normally.
6517            _ if line_terminator_len(bytes, i) > 0 => return i,
6518            _ => {}
6519        }
6520        i += 1;
6521    }
6522    i += 1;
6523    while i < bytes.len() && is_js_ident(bytes[i]) {
6524        i += 1;
6525    }
6526    i
6527}
6528
6529#[cfg(test)]
6530mod prop_tests {
6531    //! Property-based tests for the eval auto-return heuristic — the code that
6532    //! caused the worst bug in the system (silent corruption of multi-statement
6533    //! eval) and has bitten twice. These generate many JS-ish snippets and
6534    //! assert the invariants that keep eval correct.
6535    use super::should_prepend_return;
6536    use proptest::prelude::*;
6537
6538    /// A small set of non-keyword identifier-ish expressions.
6539    fn ident() -> impl Strategy<Value = String> {
6540        prop_oneof![
6541            Just("a".to_string()),
6542            Just("x".to_string()),
6543            Just("foo".to_string()),
6544            Just("window.x".to_string()),
6545            Just("document.title".to_string()),
6546            Just("obj.prop".to_string()),
6547            Just("arr[0]".to_string()),
6548            Just("localStorage".to_string()),
6549        ]
6550    }
6551
6552    /// A single bare expression: never starts with a statement keyword, has no
6553    /// top-level `;`, and contains no `return`.
6554    fn bare_expr() -> impl Strategy<Value = String> {
6555        prop_oneof![
6556            ident(),
6557            (ident(), ident()).prop_map(|(a, b)| format!("{a} + {b}")),
6558            (ident(), ident()).prop_map(|(a, b)| format!("{a}({b})")),
6559            ident().prop_map(|a| format!("{a}.length")),
6560            any::<u16>().prop_map(|n| n.to_string()),
6561        ]
6562    }
6563
6564    proptest! {
6565        /// Must never panic or hang on ANY input — including malformed code,
6566        /// unbalanced quotes, and arbitrary unicode (the scanner indexes bytes).
6567        #[test]
6568        fn never_panics_on_arbitrary_input(s in ".{0,256}") {
6569            let _ = should_prepend_return(&s);
6570        }
6571
6572        /// A single bare expression is safe to wrap with `return` → true.
6573        #[test]
6574        fn bare_expressions_are_prepended(e in bare_expr()) {
6575            prop_assert!(should_prepend_return(&e), "bare expr not prepended: {e:?}");
6576        }
6577
6578        /// THE critical bug class: `<expr>; return <expr>` must NOT be prepended
6579        /// (else `return <expr>;` runs and the rest is silently discarded).
6580        #[test]
6581        fn semicolon_multistatement_with_return_never_prepended(
6582            setup in bare_expr(), ret in bare_expr()
6583        ) {
6584            let code = format!("{setup}; return {ret}");
6585            prop_assert!(!should_prepend_return(&code), "would corrupt: {code:?}");
6586        }
6587
6588        /// Newline-separated (ASI) explicit return must also be left as-is.
6589        #[test]
6590        fn newline_explicit_return_never_prepended(pre in bare_expr(), ret in bare_expr()) {
6591            let code = format!("{pre}\nreturn {ret}");
6592            prop_assert!(!should_prepend_return(&code), "explicit return prepended: {code:?}");
6593        }
6594
6595        /// A line after a postfix `++`/`--`, or after a keyword-named PROPERTY (`obj.of`,
6596        /// `obj.in`), starts a new statement: prepending would silently drop it (audit V-6).
6597        #[test]
6598        fn statement_after_postfix_or_keyword_property_never_prepended(
6599            a in ident(), b in bare_expr(), op in prop_oneof![Just("++"), Just("--")],
6600            open in prop_oneof![Just("["), Just("(")], prop in prop_oneof![Just("of"), Just("in")]
6601        ) {
6602            let close = if open == "[" { "]" } else { ")" };
6603            let postfix = format!("{a}{op}\n{open}{b}{close}");
6604            prop_assert!(!should_prepend_return(&postfix), "would drop a line: {postfix:?}");
6605            let keyword_prop = format!("{a}.{prop}\n{b}");
6606            prop_assert!(!should_prepend_return(&keyword_prop), "would drop a line: {keyword_prop:?}");
6607        }
6608
6609        /// `;` or the word `return` INSIDE a string literal must not trigger a
6610        /// false multi-statement split — a bare string is one expression.
6611        #[test]
6612        fn semicolons_and_return_inside_strings_are_ignored(inner in "[a-z0-9;= ]{0,24}") {
6613            // `inner` never contains a quote, so the literal is well-formed.
6614            let code = format!("'do;not;split return {inner}'");
6615            prop_assert!(should_prepend_return(&code), "string literal mis-split: {code:?}");
6616        }
6617    }
6618}
6619
6620#[cfg(test)]
6621mod tests {
6622    use super::*;
6623
6624    #[cfg(feature = "sqlite")]
6625    #[test]
6626    fn database_path_resolution_rejects_lexical_escape() {
6627        let dir = tempfile::tempdir().unwrap();
6628        let root = dir.path().join("allowed");
6629        std::fs::create_dir(&root).unwrap();
6630        std::fs::File::create(dir.path().join("outside.db")).unwrap();
6631
6632        let err =
6633            VictauriMcpHandler::resolve_existing_db_path(&[root], "../outside.db").unwrap_err();
6634        assert!(err.contains("path traversal"), "unexpected error: {err}");
6635    }
6636
6637    #[cfg(feature = "sqlite")]
6638    #[test]
6639    fn database_path_resolution_accepts_contained_nested_file() {
6640        let dir = tempfile::tempdir().unwrap();
6641        let root = dir.path().join("allowed");
6642        let nested = root.join("nested");
6643        std::fs::create_dir_all(&nested).unwrap();
6644        let db = nested.join("app.db");
6645        std::fs::File::create(&db).unwrap();
6646
6647        let resolved =
6648            VictauriMcpHandler::resolve_existing_db_path(&[root], "nested/app.db").unwrap();
6649        // Resolution returns the CANONICAL validated path (opened == validated, closing the
6650        // lexical-vs-canonical TOCTOU), so compare against the canonical form of the target.
6651        assert_eq!(resolved, std::fs::canonicalize(&db).unwrap());
6652    }
6653
6654    #[cfg(all(feature = "sqlite", unix))]
6655    #[test]
6656    fn database_path_resolution_rejects_symlink_escape() {
6657        use std::os::unix::fs::symlink;
6658
6659        let dir = tempfile::tempdir().unwrap();
6660        let root = dir.path().join("allowed");
6661        std::fs::create_dir(&root).unwrap();
6662        let outside = dir.path().join("outside.db");
6663        std::fs::File::create(&outside).unwrap();
6664        symlink(&outside, root.join("linked.db")).unwrap();
6665
6666        let err =
6667            VictauriMcpHandler::resolve_existing_db_path(std::slice::from_ref(&root), "linked.db")
6668                .unwrap_err();
6669        // Audit F6: an escape answers exactly like a miss (no existence oracle).
6670        let miss = VictauriMcpHandler::resolve_existing_db_path(&[root], "absent.db").unwrap_err();
6671        assert!(
6672            err.contains("database not found"),
6673            "unexpected error: {err}"
6674        );
6675        assert_eq!(
6676            err.replace("linked.db", "X"),
6677            miss.replace("absent.db", "X")
6678        );
6679    }
6680
6681    #[cfg(feature = "sqlite")]
6682    #[test]
6683    fn sqlite_identifier_quoting_handles_hostile_table_names() {
6684        let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap();
6685        let conn = rusqlite::Connection::open(file.path()).unwrap();
6686        let name = "odd\"] table";
6687        let identifier = crate::database::quote_sqlite_identifier(name);
6688        conn.execute_batch(&format!(
6689            "CREATE TABLE {identifier} (id INTEGER); INSERT INTO {identifier} VALUES (1);"
6690        ))
6691        .unwrap();
6692        let count: i64 = conn
6693            .query_row(&format!("SELECT count(*) FROM {identifier}"), [], |row| {
6694                row.get(0)
6695            })
6696            .unwrap();
6697        assert_eq!(count, 1);
6698    }
6699
6700    #[test]
6701    fn env_filter_drops_secrets_keeps_safe() {
6702        // Safe, non-secret vars pass.
6703        assert!(is_safe_env_key("HOME"));
6704        assert!(is_safe_env_key("LANG"));
6705        assert!(is_safe_env_key("TAURI_ENV_PLATFORM"));
6706        assert!(is_safe_env_key("VICTAURI_PORT"));
6707        // Secret-looking vars are dropped even under a safe prefix (audit #5).
6708        assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY"));
6709        assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"));
6710        assert!(!is_safe_env_key("VICTAURI_AUTH_TOKEN"));
6711        assert!(!is_safe_env_key("VICTAURI_API_KEY"));
6712        // Unknown prefixes are dropped regardless.
6713        assert!(!is_safe_env_key("AWS_SECRET_ACCESS_KEY"));
6714        assert!(!is_safe_env_key("RANDOM_VAR"));
6715        // The broad TAURI_ namespace is no longer allowed — only TAURI_ENV_ — so
6716        // app-custom TAURI_ secrets are dropped even without a denylist hit.
6717        assert!(!is_safe_env_key("TAURI_CUSTOM_THING"));
6718        // Adversarial leaks closed (audit #5 follow-up): connection strings,
6719        // passphrases, PATs, JWTs, etc. under an allowed prefix.
6720        assert!(!is_safe_env_key("VICTAURI_DB_DSN"));
6721        assert!(!is_safe_env_key("VICTAURI_SIGNING_PASSPHRASE"));
6722        assert!(!is_safe_env_key("VICTAURI_GH_PAT"));
6723        assert!(!is_safe_env_key("VICTAURI_JWT"));
6724        assert!(!is_safe_env_key("VICTAURI_SESSION_ID"));
6725    }
6726
6727    #[test]
6728    fn prepend_return_newline_separated_statements_are_not_wrapped() {
6729        // ASI: `return foo()\nbar()` returns foo() and never runs bar().
6730        assert!(!should_prepend_return("foo()\nbar()"));
6731        assert!(!should_prepend_return("window.x = 1\nwindow.x + 1"));
6732        assert!(!should_prepend_return("foo() // note\nbar()"));
6733        // …but an expression that visibly continues across lines is still one expression.
6734        assert!(should_prepend_return(
6735            "document\n  .querySelector('x')\n  .textContent"
6736        ));
6737        assert!(should_prepend_return("a +\n  b"));
6738        assert!(should_prepend_return("cond\n  ? a\n  : b"));
6739        assert!(should_prepend_return("[1, 2].map(x =>\n  x * 2)"));
6740        assert!(should_prepend_return("`line1\nline2`"));
6741        assert!(should_prepend_return("document.title\n"));
6742    }
6743
6744    /// The live tool list (name + description), in router order — the source of truth for the
6745    /// CLI bridge's baked `tools_fallback.json` (what an agent sees while the app is down).
6746    fn live_tool_manifest() -> serde_json::Value {
6747        let mut tools = VictauriMcpHandler::tool_router().list_all();
6748        tools.sort_by(|a, b| a.name.cmp(&b.name));
6749        serde_json::Value::Array(
6750            tools
6751                .iter()
6752                .map(|t| {
6753                    serde_json::json!({
6754                        "name": t.name.as_ref(),
6755                        "description": t.description.as_deref().unwrap_or_default(),
6756                    })
6757                })
6758                .collect(),
6759        )
6760    }
6761
6762    fn fallback_manifest_path() -> std::path::PathBuf {
6763        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
6764            .join("../victauri-cli/src/tools_fallback.json")
6765    }
6766
6767    #[test]
6768    fn cli_fallback_tool_manifest_matches_the_live_tools() {
6769        // Names AND descriptions: a stale description shown while the app is down misleads an
6770        // agent just as much as a missing tool. Regenerate with:
6771        //   VICTAURI_WRITE_FALLBACK=1 cargo test -p victauri-plugin --lib cli_fallback
6772        let path = fallback_manifest_path();
6773        let Ok(raw) = std::fs::read_to_string(&path) else {
6774            return; // packaged crate: the CLI source is not alongside
6775        };
6776        let live = live_tool_manifest();
6777        if std::env::var_os("VICTAURI_WRITE_FALLBACK").is_some() {
6778            let pretty = serde_json::to_string_pretty(&live).unwrap() + "\n";
6779            std::fs::write(&path, pretty).unwrap();
6780            return;
6781        }
6782        let mut baked: Vec<serde_json::Value> = serde_json::from_str(&raw).unwrap();
6783        baked.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
6784        assert_eq!(
6785            serde_json::Value::Array(baked),
6786            live,
6787            "crates/victauri-cli/src/tools_fallback.json is stale — regenerate it with \
6788             VICTAURI_WRITE_FALLBACK=1 cargo test -p victauri-plugin --lib cli_fallback"
6789        );
6790    }
6791
6792    #[cfg(feature = "sqlite")]
6793    #[test]
6794    fn db_path_outside_roots_gives_no_existence_oracle() {
6795        let root = tempfile::tempdir().unwrap();
6796        let roots = vec![root.path().to_path_buf()];
6797        let outside_existing = std::env::current_exe().unwrap();
6798        let outside_missing = outside_existing.with_file_name("definitely-not-here-7f3a.db");
6799        let e1 = VictauriMcpHandler::resolve_existing_db_path(
6800            &roots,
6801            outside_existing.to_str().unwrap(),
6802        )
6803        .unwrap_err();
6804        let e2 =
6805            VictauriMcpHandler::resolve_existing_db_path(&roots, outside_missing.to_str().unwrap())
6806                .unwrap_err();
6807        assert!(e1.contains("not within an allowed directory"), "{e1}");
6808        assert!(e2.contains("not within an allowed directory"), "{e2}");
6809        assert!(!e1.contains("not found") && !e2.contains("not found"));
6810        // `..` is refused outright in an absolute path (audit F6: the OS resolves it after
6811        // following any symlink, so lexical normalization cannot vouch for it).
6812        let climb = root.path().join("..").join("x.db");
6813        let e3 = VictauriMcpHandler::resolve_existing_db_path(&roots, climb.to_str().unwrap())
6814            .unwrap_err();
6815        assert!(e3.contains("'..' is rejected"), "{e3}");
6816    }
6817
6818    #[test]
6819    fn prepend_return_handles_the_red_team_asi_cases() {
6820        // Two statements that must NOT be wrapped (the second line would silently never run).
6821        for code in [
6822            "window.x++\nwindow.x",
6823            "window.x--\nwindow.x",
6824            "a\n++b",
6825            "/re/.test(s)\nfoo()",
6826            "s.replace(/\"/g, '')\nfoo()",
6827            "foo()\u{2028}bar()",
6828            "foo()\u{2029}bar()",
6829        ] {
6830            assert!(!should_prepend_return(code), "must not wrap: {code:?}");
6831        }
6832        // Valid single expressions spanning lines, which must still be wrapped.
6833        for code in [
6834            "a instanceof\nB",
6835            "typeof\nx",
6836            "await\nfoo()",
6837            "new\nFoo()",
6838            "a /\nb",
6839            "s.split(/,/)\n.length",
6840            "document.title; // trailing note",
6841            "document.title // trailing note",
6842            "obj.return",
6843            "obj.of\n.length",
6844            "i++ / 2",
6845            "`a${'`'}b`",
6846        ] {
6847            assert!(should_prepend_return(code), "must wrap: {code:?}");
6848        }
6849        // Round 2 (0.9 audit V-6): each of these used to be wrapped and silently lost a
6850        // statement, or was wrapped into a syntax error.
6851        for code in ASI_ROUND2_STATEMENT_CASES {
6852            assert!(!should_prepend_return(code), "must not wrap: {code:?}");
6853        }
6854    }
6855
6856    /// Run a Node script and return its stdout, or `None` (test skipped) without `node`.
6857    fn run_node(script: &str) -> Option<String> {
6858        let out = std::process::Command::new("node")
6859            .arg("-e")
6860            .arg(script)
6861            .output()
6862            .ok()?;
6863        Some(String::from_utf8_lossy(&out.stdout).into_owned())
6864    }
6865
6866    #[test]
6867    fn log_field_truncation_never_splits_a_surrogate_pair() {
6868        // An emoji straddling the MAX_LOG_FIELD_BYTES cut used to leave a lone high surrogate,
6869        // which serde_json rejects — failing the whole `logs` read (audit V-4).
6870        let source = format!(
6871            "[{{ body: 'a'.repeat({}) + '\\u{{1F600}}tail' }}]",
6872            MAX_LOG_FIELD_BYTES - 1
6873        );
6874        let code = trimmed_log_js(&source, 10);
6875        let script = format!(
6876            "const r = (function() {{ {code} }})(); \
6877             console.log('OUT:' + r[0].body.isWellFormed() + ':' + r[0].body.indexOf('bytes truncated'));"
6878        );
6879        let Some(out) = run_node(&script) else {
6880            eprintln!("SKIP: node not installed");
6881            return;
6882        };
6883        let line = out
6884            .lines()
6885            .find_map(|l| l.strip_prefix("OUT:"))
6886            .unwrap_or_else(|| panic!("no output: {out}"));
6887        let (well_formed, marker_at) = line.split_once(':').unwrap();
6888        assert_eq!(
6889            well_formed, "true",
6890            "truncated field is not well-formed UTF-16"
6891        );
6892        assert_ne!(marker_at, "-1", "field was not truncated");
6893    }
6894
6895    /// Multi-statement snippets from the 0.9 red-team round (audit V-6): each must run every
6896    /// statement. `f()` pushes `'f'` onto the log.
6897    const ASI_ROUND2_STATEMENT_CASES: &[&str] = &[
6898        "i++ / 2; f()",
6899        "x = {} / 1; f()",
6900        "i++\n[f()]",
6901        "i++\n(f)()",
6902        "i--\n[f()]",
6903        "i--\n(f)()",
6904        "obj.of\nf()",
6905        "obj.in\nf()",
6906        "if\t(true) f()",
6907        "if\n(true) f()",
6908        "const\ny = 5; f()",
6909        "outer: for (const a of [1]) { f() }",
6910        "outer:\nfor (const a of [1]) { f() }",
6911        "`a${'`'}b`; f()",
6912        "`${'`'}`\nf()",
6913        "f() <!-- don't\nf()",
6914        "f()\n--> it's a comment\nf()",
6915        "a = /[/]/\nf()",
6916        "typeof x / 2; f()",
6917    ];
6918
6919    /// Multi-statement snippets from the 0.9 round-4 audit (R4-EVAL2): each was wrapped with
6920    /// `return` and silently lost everything after its first statement.
6921    const ASI_ROUND4_STATEMENT_CASES: &[&str] = &[
6922        // (a) a block comment spanning a line break is a line break
6923        "x = 1 /*\n*/ f()",
6924        "x = 1 /*\r*/ f()",
6925        "x = 1 /*\u{2028}*/ f()",
6926        "x = 1 /* a\n b */ f()",
6927        // (b) a lone CR (and CRLF) is a line terminator everywhere
6928        "x = 1\rf()",
6929        "x = 1\r\nf()",
6930        "x = 1 // note\rf()",
6931        "x = 1 // note\u{2028}f()",
6932        "f() <!-- c\rf()",
6933        "f()\r--> c\rf()",
6934        "x = /a/g\rf()",
6935        // (c) a number ending in `.` is a complete operand
6936        "x = 1.\nf()",
6937        "x = 1_0.\rf()",
6938        "x = 10.\n\nf()",
6939        // (d) `yield` is an identifier in the eval wrapper; `of` is one at depth 0
6940        "x = typeof yield\nf()",
6941        "x = typeof of\nf()",
6942    ];
6943
6944    /// Multi-statement snippets from the 0.9 round-5 audit (R5-EVAL1): each was wrapped with
6945    /// `return` and lost its second statement. (1) a regex right after the `)` of an
6946    /// `if`/`while`/`for`/`with` head was read as division, so a quote or bracket inside it
6947    /// broke the scan; (2) `of` was a keyword anywhere inside brackets; (3) a non-ASCII
6948    /// identifier ending in a keyword (`énew`) was read as that keyword.
6949    const ASI_ROUND5_STATEMENT_CASES: &[&str] = &[
6950        "(function(){ if (a) /'/.test('q') })(); f()",
6951        "(function(){ if (a) /\\(/.test('q') })(); f()",
6952        "(function(){ if (a) /\"/.test('q') })(); f()",
6953        "(function(){ if (a) /`/.test('q') })(); f()",
6954        "(function(){ if (a) /[(]/.test('q') })(); f()",
6955        "(function(){ if (a) /\\[/.test('q') })(); f()",
6956        "(function(){ if (a) /\\{/.test('q') })(); f()",
6957        "(function(){ if ((a)) /'/.test('q') })(); f()",
6958        "(function(){ while (a) /'/.test('q') })(); f()",
6959        "(function(){ for (;a;) /'/.test('q') })(); f()",
6960        "(function(){ with (obj) /'/.test('q') })(); f()",
6961        "(async function(){ for await (const q of []) /'/.test('q') })(); f()",
6962        "(() => { if (a) /'/.test('q') })(); f()",
6963        "Math.abs(of / 2); f()",
6964        "Math.abs(of / 2); f() / 1",
6965        "[of / 2]; f()",
6966        "énew / 2; f() / 1",
6967        "x = énew / 2; f()",
6968        "x = \u{e9}typeof / 2; f()",
6969    ];
6970
6971    #[test]
6972    fn prepend_return_round5_shapes() {
6973        for code in ASI_ROUND5_STATEMENT_CASES {
6974            assert!(!should_prepend_return(code), "must not wrap: {code:?}");
6975        }
6976        for code in [
6977            "(x) / 2 / 1",
6978            "f(x) / 2",
6979            "f(of / 2)",
6980            "[1, 2].map(of => of / 2)",
6981            "énew / 2",
6982            "if_ (a) / 2",
6983            "obj.if (a) / 2",
6984            "(a) / 2; ",
6985        ] {
6986            assert!(should_prepend_return(code), "must wrap: {code:?}");
6987        }
6988    }
6989
6990    /// Table for R4-EVAL2: `(code, wrap?)` — every shape the fix touches, both ways.
6991    #[test]
6992    fn prepend_return_round4_shapes() {
6993        for code in ASI_ROUND4_STATEMENT_CASES {
6994            assert!(!should_prepend_return(code), "must not wrap: {code:?}");
6995        }
6996        for code in [
6997            "x /* no line break */ + 1",
6998            "x /*\n*/ + 1",
6999            "x +\r1",
7000            "x\r.toString()",
7001            "a instanceof\r\nB",
7002            "1.5\n.toFixed(1)",
7003            "obj.of.\nlength",
7004            "0x10.\ntoString()",
7005            "1e3.\ntoFixed(0)",
7006            "(1).\ntoFixed(0)",
7007            "document.title // trailing note\r",
7008            "-->x\ndocument.title",
7009            "<!-- x\rdocument.title",
7010            "// lead\u{2028}document.title",
7011            "[1].map(y => { for (const z of\n[2]) {} })",
7012        ] {
7013            assert!(should_prepend_return(code), "must wrap: {code:?}");
7014        }
7015    }
7016
7017    #[test]
7018    fn line_terminators_and_number_dots_are_recognized() {
7019        for (s, at, len) in [
7020            ("a\nb", 1, 1),
7021            ("a\rb", 1, 1),
7022            ("a\r\nb", 1, 2),
7023            ("a\u{2028}b", 1, 3),
7024            ("a\u{2029}b", 1, 3),
7025            ("a b", 1, 0),
7026            ("a\u{2027}b", 1, 0),
7027        ] {
7028            assert_eq!(line_terminator_len(s.as_bytes(), at), len, "{s:?}");
7029        }
7030        for (code, complete) in [
7031            ("1.", true),
7032            ("x = 10.", true),
7033            ("1_000.", true),
7034            ("a1.", false),
7035            ("x.", false),
7036            ("0x1.", false),
7037            ("1e3.", false),
7038            ("1n.", false),
7039            ("1.5.", false),
7040            ("(1).", false),
7041            ("1 .", false),
7042        ] {
7043            assert_eq!(
7044                dot_completes_number(code, code.len() - 1),
7045                complete,
7046                "{code:?}"
7047            );
7048        }
7049    }
7050
7051    /// Run each `(code, expected_return, expected_log)` through the REAL eval wrapper shape
7052    /// (the code inlined in an async arrow, prepended with `return` exactly when
7053    /// [`should_prepend_return`] says so) in Node, and check every statement ran and the
7054    /// right value came back. Skips when `node` is not installed.
7055    #[test]
7056    fn prepend_return_decisions_run_every_statement_in_node() {
7057        let mut cases: Vec<(&str, &str, Vec<&str>)> = ASI_ROUND2_STATEMENT_CASES
7058            .iter()
7059            .chain(ASI_ROUND4_STATEMENT_CASES)
7060            .chain(ASI_ROUND5_STATEMENT_CASES)
7061            .map(|c| {
7062                let n = c.matches("f()").count() + c.matches("(f)()").count();
7063                (*c, "undefined", vec!["f"; n])
7064            })
7065            .collect();
7066        cases.extend([
7067            ("obj.return", "\"RET\"", vec![]),
7068            ("obj.of", "\"OF\"", vec![]),
7069            ("obj.in\n.length", "2", vec![]),
7070            ("i++ / 2", "0.5", vec![]),
7071            ("`a${'`'}b`", "\"a`b\"", vec![]),
7072            ("f() <!-- trailing html comment", "1", vec!["f"]),
7073            ("f()\n+ 1", "2", vec!["f"]),
7074            ("document", "\"doc\"", vec![]),
7075            ("x +\r1", "1", vec![]),
7076            ("x /*\n*/ + 1", "1", vec![]),
7077            ("obj.of.\nlength", "2", vec![]),
7078            ("0x10.\ntoString()", "\"16\"", vec![]),
7079            ("-->x\ndocument", "\"doc\"", vec![]),
7080        ]);
7081        let bodies: Vec<String> = cases
7082            .iter()
7083            .map(|(code, _, _)| {
7084                let body = strip_leading_js_comments(code.trim());
7085                if should_prepend_return(body) {
7086                    format!("return {body}")
7087                } else {
7088                    code.trim().to_string()
7089                }
7090            })
7091            .collect();
7092        let mut file = tempfile::NamedTempFile::new().unwrap();
7093        std::io::Write::write_all(
7094            &mut file,
7095            serde_json::to_string(&bodies).unwrap().as_bytes(),
7096        )
7097        .unwrap();
7098        let runner = r"
7099            const vm = require('vm');
7100            const bodies = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'));
7101            (async () => {
7102              const out = [];
7103              for (const body of bodies) {
7104                const log = [];
7105                globalThis.__log = log;
7106                const src = '(async () => { const log = globalThis.__log; const f = () => log.push(\'f\');'
7107                  + ' let i = 1, x = 0, a, of = 4, énew = 2, étypeof = 3; const document = \'doc\';'
7108                  + ' const obj = { of: \'OF\', in: \'IN\', return: \'RET\' };\n' + body + '\n })()';
7109                let ret, err = null;
7110                try { ret = await vm.runInThisContext(src); } catch (e) { err = e.name + ': ' + e.message; }
7111                out.push({ ret: ret === undefined ? 'undefined' : JSON.stringify(ret), log, err });
7112              }
7113              console.log('ASI_RESULTS:' + JSON.stringify(out));
7114            })();
7115        ";
7116        let Ok(output) = std::process::Command::new("node")
7117            .arg("-e")
7118            .arg(runner)
7119            .arg(file.path())
7120            .output()
7121        else {
7122            eprintln!("SKIP: node not installed");
7123            return;
7124        };
7125        let stdout = String::from_utf8_lossy(&output.stdout);
7126        let line = stdout
7127            .lines()
7128            .find_map(|l| l.strip_prefix("ASI_RESULTS:"))
7129            .unwrap_or_else(|| {
7130                panic!(
7131                    "no results: {stdout}\n{}",
7132                    String::from_utf8_lossy(&output.stderr)
7133                )
7134            });
7135        let results: Vec<serde_json::Value> = serde_json::from_str(line).unwrap();
7136        let mut failures = Vec::new();
7137        for (((code, want_ret, want_log), body), got) in cases.iter().zip(&bodies).zip(&results) {
7138            let got_log: Vec<&str> = got["log"]
7139                .as_array()
7140                .unwrap()
7141                .iter()
7142                .filter_map(|v| v.as_str())
7143                .collect();
7144            if !got["err"].is_null() || got["ret"] != *want_ret || got_log != *want_log {
7145                failures.push(format!(
7146                    "{code:?} (ran as {body:?}): got ret={} log={got_log:?} err={}; want ret={want_ret} log={want_log:?}",
7147                    got["ret"], got["err"]
7148                ));
7149            }
7150        }
7151        assert!(failures.is_empty(), "\n{}", failures.join("\n"));
7152    }
7153
7154    #[test]
7155    fn prepend_return_sees_past_leading_comments() {
7156        assert!(!should_prepend_return("// setup\nconst x = 1; x"));
7157        assert!(!should_prepend_return("/* c */ if (a) b()"));
7158        assert!(should_prepend_return("// read it\ndocument.title"));
7159        assert_eq!(
7160            strip_leading_js_comments("// a\n/* b */  document.title"),
7161            "document.title"
7162        );
7163        assert_eq!(strip_leading_js_comments("// only a comment"), "");
7164    }
7165
7166    #[test]
7167    fn prepend_return_bare_expressions() {
7168        assert!(should_prepend_return("document.title"));
7169        assert!(should_prepend_return("5 + 5"));
7170        assert!(should_prepend_return("\"justexpr\""));
7171        assert!(should_prepend_return("await fetch('/x')"));
7172        assert!(should_prepend_return(
7173            "document.querySelectorAll('a').length"
7174        ));
7175        assert!(should_prepend_return("x ? a : b"));
7176        // Single trailing semicolon on a bare expression is still an expression.
7177        assert!(should_prepend_return("document.title;"));
7178        // Semicolons inside strings must not be treated as boundaries.
7179        assert!(should_prepend_return("'a;b;c'"));
7180        assert!(should_prepend_return("\"x;y\".length"));
7181        // IIFE workaround: the `;` lives inside the arrow body (depth > 0).
7182        assert!(should_prepend_return("(()=>{window.x=5; return 'ok'})()"));
7183    }
7184
7185    #[test]
7186    fn no_prepend_for_statement_blocks() {
7187        // The original silent-corruption cases.
7188        assert!(!should_prepend_return(
7189            "localStorage.setItem('k','v'); return localStorage.getItem('k')"
7190        ));
7191        assert!(!should_prepend_return(
7192            "window.scrollTo(0,50); return window.scrollY"
7193        ));
7194        assert!(!should_prepend_return("console.log('x'); return 123"));
7195        assert!(!should_prepend_return("window.__z=7; return 'ok'"));
7196        // Explicit return without a preceding semicolon (newline-separated).
7197        assert!(!should_prepend_return("window.x = 5\nreturn window.x"));
7198    }
7199
7200    #[test]
7201    fn no_prepend_for_statement_keywords() {
7202        assert!(!should_prepend_return("return 42"));
7203        assert!(!should_prepend_return("const x = 1; return x"));
7204        assert!(!should_prepend_return("let y = 2"));
7205        assert!(!should_prepend_return("var z = 3"));
7206        assert!(!should_prepend_return("if (x) { return 1 }"));
7207        assert!(!should_prepend_return("for (const x of y) doThing(x)"));
7208        assert!(!should_prepend_return("throw new Error('x')"));
7209        assert!(!should_prepend_return("function f(){}"));
7210        assert!(!should_prepend_return("{ a: 1 }")); // object-literal-as-block ambiguity → as-is
7211    }
7212
7213    #[test]
7214    fn empty_code_no_prepend() {
7215        assert!(!should_prepend_return(""));
7216        assert!(!should_prepend_return("   "));
7217    }
7218
7219    #[test]
7220    fn envelope_unwrap_value() {
7221        assert_eq!(
7222            unwrap_eval_envelope(r#"{"__victauri_ok":"4DA","__victauri_type":"value"}"#.into()),
7223            Ok("\"4DA\"".to_string())
7224        );
7225        assert_eq!(
7226            unwrap_eval_envelope(r#"{"__victauri_ok":42,"__victauri_type":"value"}"#.into()),
7227            Ok("42".to_string())
7228        );
7229    }
7230
7231    #[test]
7232    fn envelope_unwrap_undefined_null() {
7233        assert_eq!(
7234            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#.into()),
7235            Ok("undefined".to_string())
7236        );
7237        assert_eq!(
7238            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"null"}"#.into()),
7239            Ok("null".to_string())
7240        );
7241    }
7242
7243    #[test]
7244    fn envelope_unwrap_error() {
7245        let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into());
7246        assert!(r.unwrap_err().message.contains("boom"));
7247    }
7248
7249    #[test]
7250    fn envelope_unwrap_deeply_nested_does_not_leak() {
7251        // Build an envelope whose value is nested far deeper than serde_json's
7252        // default recursion limit (128). The full parse fails, so the slice
7253        // fallback must return the value — NOT the raw `__victauri_ok` envelope.
7254        let mut value = String::from("0");
7255        for _ in 0..300 {
7256            value = format!("{{\"n\":{value}}}");
7257        }
7258        let raw = format!(r#"{{"__victauri_ok":{value},"__victauri_type":"value"}}"#);
7259        let out = unwrap_eval_envelope(raw).unwrap();
7260        assert!(
7261            out.starts_with(r#"{"n":"#),
7262            "deep value should be unwrapped, got: {}",
7263            &out[..out.len().min(40)]
7264        );
7265        assert!(
7266            !out.contains("__victauri_ok"),
7267            "envelope must not leak into the result"
7268        );
7269    }
7270
7271    #[test]
7272    fn js_string_simple() {
7273        assert_eq!(js_string("hello"), "\"hello\"");
7274    }
7275
7276    #[test]
7277    fn js_string_single_quotes() {
7278        let result = js_string("it's a test");
7279        assert!(result.contains("it's a test"));
7280    }
7281
7282    #[test]
7283    fn js_string_double_quotes() {
7284        let result = js_string(r#"say "hello""#);
7285        assert!(result.contains(r#"\""#));
7286    }
7287
7288    #[test]
7289    fn js_string_backslashes() {
7290        let result = js_string(r"path\to\file");
7291        assert!(result.contains(r"\\"));
7292    }
7293
7294    #[test]
7295    fn js_string_newlines_and_tabs() {
7296        let result = js_string("line1\nline2\ttab");
7297        assert!(result.contains(r"\n"));
7298        assert!(result.contains(r"\t"));
7299        assert!(!result.contains('\n'));
7300    }
7301
7302    #[test]
7303    fn js_string_null_bytes() {
7304        let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
7305        let result = js_string(&input);
7306        // serde_json escapes null bytes as
7307        assert!(result.contains("\\u0000"));
7308        assert!(!result.contains('\0'));
7309    }
7310
7311    #[test]
7312    fn js_string_template_literal_injection() {
7313        let result = js_string("`${alert(1)}`");
7314        // Should not contain unescaped backticks that could break template literals
7315        // serde_json wraps in double quotes, so backticks are safe
7316        assert!(result.starts_with('"'));
7317        assert!(result.ends_with('"'));
7318    }
7319
7320    #[test]
7321    fn js_string_unicode_separators() {
7322        // U+2028 (Line Separator) and U+2029 (Paragraph Separator) are valid in
7323        // JSON strings per RFC 8259, and serde_json passes them through literally.
7324        // Since js_string is used inside JS double-quoted strings (not template
7325        // literals), they are safe in modern JS engines (ES2019+).
7326        let result = js_string("a\u{2028}b\u{2029}c");
7327        // Verify the string is valid JSON that round-trips correctly
7328        let decoded: String = serde_json::from_str(&result).unwrap();
7329        assert_eq!(decoded, "a\u{2028}b\u{2029}c");
7330    }
7331
7332    #[test]
7333    fn js_string_empty() {
7334        assert_eq!(js_string(""), "\"\"");
7335    }
7336
7337    #[test]
7338    fn js_string_html_script_close() {
7339        // </script> in a JS string inside HTML could break out of script tags
7340        let result = js_string("</script><img onerror=alert(1)>");
7341        assert!(result.starts_with('"'));
7342        // The string is JSON-encoded; verify it round-trips safely
7343        let decoded: String = serde_json::from_str(&result).unwrap();
7344        assert_eq!(decoded, "</script><img onerror=alert(1)>");
7345    }
7346
7347    #[test]
7348    fn js_string_very_long() {
7349        let long = "a".repeat(100_000);
7350        let result = js_string(&long);
7351        assert!(result.len() >= 100_002); // quotes + content
7352    }
7353
7354    // ── URL validation tests ────────────────────────────────────────────────
7355
7356    #[test]
7357    fn url_allows_http() {
7358        assert!(validate_url("http://example.com", false).is_ok());
7359    }
7360
7361    #[test]
7362    fn url_allows_https() {
7363        assert!(validate_url("https://example.com/path?q=1", false).is_ok());
7364    }
7365
7366    #[test]
7367    fn url_allows_http_localhost() {
7368        assert!(validate_url("http://localhost:3000", false).is_ok());
7369    }
7370
7371    #[test]
7372    fn url_blocks_file_by_default() {
7373        let err = validate_url("file:///etc/passwd", false).unwrap_err();
7374        assert!(err.contains("file"), "error should mention the file scheme");
7375    }
7376
7377    #[test]
7378    fn url_allows_file_when_opted_in() {
7379        assert!(validate_url("file:///tmp/test.html", true).is_ok());
7380    }
7381
7382    #[test]
7383    fn url_blocks_javascript() {
7384        assert!(validate_url("javascript:alert(1)", false).is_err());
7385    }
7386
7387    #[test]
7388    fn url_blocks_javascript_case_insensitive() {
7389        assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
7390    }
7391
7392    #[test]
7393    fn url_blocks_data_scheme() {
7394        assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
7395    }
7396
7397    #[test]
7398    fn url_blocks_vbscript() {
7399        assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
7400    }
7401
7402    #[test]
7403    fn url_rejects_invalid() {
7404        assert!(validate_url("not a url at all", false).is_err());
7405    }
7406
7407    #[test]
7408    fn url_strips_control_chars() {
7409        // Control characters should be stripped, leaving a valid URL
7410        let input = format!("http://example{}com", '\0');
7411        assert!(validate_url(&input, false).is_ok());
7412    }
7413
7414    // ── CSS color sanitization tests ───────────────────────────────────────
7415
7416    #[test]
7417    fn css_color_valid_hex() {
7418        assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
7419        assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
7420        assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
7421    }
7422
7423    #[test]
7424    fn css_color_valid_rgb() {
7425        assert_eq!(
7426            sanitize_css_color("rgb(255, 0, 0)").unwrap(),
7427            "rgb(255, 0, 0)"
7428        );
7429        assert_eq!(
7430            sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
7431            "rgba(0, 0, 0, 0.5)"
7432        );
7433    }
7434
7435    #[test]
7436    fn css_color_valid_named() {
7437        assert_eq!(sanitize_css_color("red").unwrap(), "red");
7438        assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
7439    }
7440
7441    #[test]
7442    fn css_color_valid_hsl() {
7443        assert_eq!(
7444            sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
7445            "hsl(120, 50%, 50%)"
7446        );
7447    }
7448
7449    #[test]
7450    fn css_color_rejects_too_long() {
7451        let long = "a".repeat(101);
7452        assert!(sanitize_css_color(&long).is_err());
7453    }
7454
7455    #[test]
7456    fn css_color_rejects_backslash_escapes() {
7457        assert!(sanitize_css_color(r"red\00").is_err());
7458        assert!(sanitize_css_color(r"\72\65\64").is_err());
7459    }
7460
7461    #[test]
7462    fn css_color_rejects_url_injection() {
7463        assert!(sanitize_css_color("url(http://evil.com)").is_err());
7464        assert!(sanitize_css_color("URL(http://evil.com)").is_err());
7465    }
7466
7467    #[test]
7468    fn css_color_rejects_expression_injection() {
7469        assert!(sanitize_css_color("expression(alert(1))").is_err());
7470        assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
7471    }
7472
7473    #[test]
7474    fn css_color_rejects_import() {
7475        assert!(sanitize_css_color("@import url(evil.css)").is_err());
7476    }
7477
7478    #[test]
7479    fn css_color_rejects_semicolons_and_braces() {
7480        assert!(sanitize_css_color("red; background: url(evil)").is_err());
7481        assert!(sanitize_css_color("red} body { color: blue").is_err());
7482    }
7483
7484    #[test]
7485    fn css_color_rejects_special_chars() {
7486        assert!(sanitize_css_color("red<script>").is_err());
7487        assert!(sanitize_css_color("red\"onload=alert").is_err());
7488        assert!(sanitize_css_color("red'onclick=alert").is_err());
7489    }
7490
7491    #[test]
7492    fn css_color_trims_whitespace() {
7493        assert_eq!(sanitize_css_color("  red  ").unwrap(), "red");
7494    }
7495
7496    #[test]
7497    fn css_color_empty_string() {
7498        assert_eq!(sanitize_css_color("").unwrap(), "");
7499    }
7500}
7501
7502/// Dispatch-level authorization tests.
7503///
7504/// These exercise the REAL `execute_tool` dispatch path (not just the privacy
7505/// string matrix) to prove that blocked tools/actions actually return
7506/// `tool_disabled` and never reach their handler. This is the negative security
7507/// suite the audit required (Gate #5): the prior tests validated
7508/// `is_tool_enabled(...)` in isolation, which let structural dispatch bypasses
7509/// pass undetected.
7510#[cfg(test)]
7511mod authz_dispatch_tests {
7512    use super::*;
7513    use crate::bridge::WebviewBridge;
7514    use crate::privacy::PrivacyConfig;
7515    use std::collections::{HashMap, HashSet};
7516    use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
7517
7518    /// A bridge whose eval always fails immediately, so an *allowed* action that
7519    /// reaches the bridge returns a non-privacy error fast (no 30s hang), while a
7520    /// *blocked* action is rejected by dispatch before the bridge is ever touched.
7521    struct RejectingBridge;
7522
7523    impl WebviewBridge for RejectingBridge {
7524        fn eval_webview(&self, _label: Option<&str>, _script: &str) -> Result<(), String> {
7525            Err("eval rejected in authz dispatch test".to_string())
7526        }
7527        fn get_window_states(&self, _label: Option<&str>) -> Vec<WindowState> {
7528            Vec::new()
7529        }
7530        fn list_window_labels(&self) -> Vec<String> {
7531            Vec::new()
7532        }
7533        fn get_native_handle(&self, _label: Option<&str>) -> Result<isize, String> {
7534            Err("no handle".to_string())
7535        }
7536        fn manage_window(&self, _label: Option<&str>, _action: &str) -> Result<String, String> {
7537            Err("no window".to_string())
7538        }
7539        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
7540            Ok(())
7541        }
7542        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
7543            Ok(())
7544        }
7545        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
7546            Ok(())
7547        }
7548    }
7549
7550    fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
7551        Arc::new(VictauriState {
7552            event_log: EventLog::new(1000),
7553            registry: CommandRegistry::new(),
7554            port: std::sync::atomic::AtomicU16::new(0),
7555            pending_evals: Arc::new(Mutex::new(HashMap::new())),
7556            recorder: EventRecorder::new(1000),
7557            privacy,
7558            eval_timeout: std::time::Duration::from_millis(100),
7559            shutdown_tx: tokio::sync::watch::channel(false).0,
7560            started_at: std::time::Instant::now(),
7561            tool_invocations: std::sync::atomic::AtomicU64::new(0),
7562            allow_file_navigation: false,
7563            command_timings: crate::introspection::CommandTimings::new(),
7564            fault_registry: crate::introspection::FaultRegistry::new(),
7565            contract_store: crate::introspection::ContractStore::new(),
7566            startup_timeline: crate::introspection::StartupTimeline::new(),
7567            event_bus: crate::introspection::EventBusMonitor::default(),
7568            task_tracker: crate::introspection::TaskTracker::new(),
7569            bridge_ready: std::sync::atomic::AtomicBool::new(true),
7570            bridge_notify: tokio::sync::Notify::new(),
7571            db_search_paths: Vec::new(),
7572            screencast: Arc::new(crate::screencast::Screencast::default()),
7573            probes: crate::introspection::AppStateProbes::default(),
7574            drain_watermarks: crate::introspection::DrainWatermarks::default(),
7575            page_loads: crate::introspection::PageLoads::default(),
7576        })
7577    }
7578
7579    fn handler(privacy: PrivacyConfig) -> VictauriMcpHandler {
7580        VictauriMcpHandler::new(state_with(privacy), Arc::new(RejectingBridge))
7581    }
7582
7583    /// True iff the result is a privacy/authorization block (vs any other error).
7584    fn is_privacy_blocked(r: &CallToolResult) -> bool {
7585        r.is_error == Some(true)
7586            && r.content.iter().any(|c| {
7587                matches!(c, ContentBlock::Text(t)
7588                    if t.text.contains("disabled by privacy configuration"))
7589            })
7590    }
7591
7592    async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
7593        match h.execute_tool(tool, args).await {
7594            Ok(r) => r,
7595            Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
7596        }
7597    }
7598
7599    /// The action strings a params type's `action` enum accepts, from its JSON schema.
7600    fn schema_actions<T: schemars::JsonSchema>() -> Vec<String> {
7601        fn collect(v: &serde_json::Value, out: &mut Vec<String>) {
7602            if let Some(values) = v.get("enum").and_then(serde_json::Value::as_array) {
7603                out.extend(values.iter().filter_map(|x| x.as_str().map(String::from)));
7604            }
7605            if let Some(c) = v.get("const").and_then(serde_json::Value::as_str) {
7606                out.push(c.to_string());
7607            }
7608            for key in ["oneOf", "anyOf"] {
7609                for sub in v
7610                    .get(key)
7611                    .and_then(serde_json::Value::as_array)
7612                    .into_iter()
7613                    .flatten()
7614                {
7615                    collect(sub, out);
7616                }
7617            }
7618        }
7619        let schema = serde_json::to_value(schemars::schema_for!(T)).unwrap();
7620        let mut action = schema["properties"]["action"].clone();
7621        if let Some(target) = action.get("$ref").and_then(serde_json::Value::as_str) {
7622            let name = target.rsplit('/').next().unwrap();
7623            action = schema["$defs"][name].clone();
7624        }
7625        let mut out = Vec::new();
7626        collect(&action, &mut out);
7627        out
7628    }
7629
7630    /// `resolve_capability` gates an unknown STRING action as the bare tool name,
7631    /// trusting the typed parse to reject it. That holds only if every action the parse
7632    /// ACCEPTS is mapped to its own capability — pinned here from the enums themselves,
7633    /// so a new variant cannot silently fall back to the bare-name gate.
7634    #[test]
7635    fn every_action_variant_has_a_capability() {
7636        let tools: &[(&str, Vec<String>)] = &[
7637            ("interact", schema_actions::<InteractParams>()),
7638            ("input", schema_actions::<InputParams>()),
7639            ("window", schema_actions::<WindowParams>()),
7640            ("storage", schema_actions::<StorageParams>()),
7641            ("navigate", schema_actions::<NavigateParams>()),
7642            ("recording", schema_actions::<RecordingParams>()),
7643            ("inspect", schema_actions::<InspectParams>()),
7644            ("css", schema_actions::<CssParams>()),
7645            ("route", schema_actions::<RouteParams>()),
7646            ("trace", schema_actions::<TraceParams>()),
7647            ("animation", schema_actions::<AnimationParams>()),
7648            ("logs", schema_actions::<LogsParams>()),
7649            ("introspect", schema_actions::<IntrospectParams>()),
7650            ("fault", schema_actions::<FaultParams>()),
7651            ("explain", schema_actions::<ExplainParams>()),
7652        ];
7653        for (tool, actions) in tools {
7654            assert!(
7655                !actions.is_empty(),
7656                "{tool}: no actions read from its schema"
7657            );
7658            for action in actions {
7659                assert!(
7660                    authz::action_capability(tool, action).is_some(),
7661                    "{tool}.{action} is accepted by the parser but has no capability"
7662                );
7663            }
7664        }
7665    }
7666
7667    /// The names `disable_tools` validates against are the live tool surface: every
7668    /// registered tool is either a compound tool or in `STANDALONE_TOOLS`, and nothing else.
7669    #[test]
7670    fn disable_tools_name_list_matches_the_live_tools() {
7671        let mut live: Vec<String> = VictauriMcpHandler::tool_router()
7672            .list_all()
7673            .into_iter()
7674            .map(|t| t.name.to_string())
7675            .filter(|n| !authz::is_compound_tool(n))
7676            .collect();
7677        let mut listed: Vec<String> = authz::STANDALONE_TOOLS
7678            .iter()
7679            .map(|s| (*s).to_string())
7680            .collect();
7681        live.sort_unstable();
7682        listed.sort_unstable();
7683        assert_eq!(live, listed);
7684        for t in VictauriMcpHandler::tool_router().list_all() {
7685            assert!(authz::is_known_name(&t.name), "{}", t.name);
7686        }
7687    }
7688
7689    /// Audit N1: `{"action": {"go_to": null}}` (a tag-shaped enum serde accepts) and a
7690    /// positional array body used to be gated as the bare tool name, which the Test
7691    /// profile allows for `navigate` — the handler then parsed and ran `go_to`. Both
7692    /// shapes must now be refused as invalid params before any handler runs, in every
7693    /// profile (`FullControl` with the action disabled is the other half of the bypass).
7694    #[tokio::test]
7695    async fn non_string_action_cannot_slip_past_the_gate() {
7696        let mut full_minus_go_to = PrivacyConfig::default();
7697        full_minus_go_to
7698            .disabled_tools
7699            .insert("navigate.go_to".to_string());
7700        for privacy in [crate::privacy::test_privacy_config(), full_minus_go_to] {
7701            let h = handler(privacy);
7702            for args in [
7703                serde_json::json!({"action": {"go_to": null}, "url": "https://evil.example"}),
7704                serde_json::json!(["go_to", "https://evil.example", null, null, null, null]),
7705                serde_json::json!({"action": 0, "url": "https://evil.example"}),
7706            ] {
7707                match h.execute_tool("navigate", args.clone()).await {
7708                    Err(rest::ToolCallError::InvalidParams(_)) => {}
7709                    Ok(r) => panic!("{args} reached dispatch: {:?}", r.content),
7710                    Err(rest::ToolCallError::UnknownTool(t)) => panic!("{args}: unknown tool {t}"),
7711                }
7712            }
7713        }
7714    }
7715
7716    // ── Observe profile: every mutation/eval/compound-action must be blocked ──
7717
7718    #[tokio::test]
7719    async fn observe_blocks_mutations_and_eval_through_dispatch() {
7720        let h = handler(crate::privacy::observe_privacy_config());
7721        let blocked: &[(&str, serde_json::Value)] = &[
7722            ("eval_js", serde_json::json!({"code": "1"})),
7723            (
7724                "wait_for",
7725                serde_json::json!({"condition": "expression", "value": "true"}),
7726            ),
7727            ("screenshot", serde_json::json!({})),
7728            ("invoke_command", serde_json::json!({"command": "greet"})),
7729            ("verify_state", serde_json::json!({"frontend_expr": "1"})),
7730            (
7731                "assert_semantic",
7732                serde_json::json!({"expression": "1", "condition": "truthy"}),
7733            ),
7734            (
7735                "interact",
7736                serde_json::json!({"action": "click", "ref_id": "e1"}),
7737            ),
7738            (
7739                "input",
7740                serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
7741            ),
7742            (
7743                "storage",
7744                serde_json::json!({"action": "set", "key": "k", "value": "v"}),
7745            ),
7746            (
7747                "storage",
7748                serde_json::json!({"action": "delete", "key": "k"}),
7749            ),
7750            (
7751                "window",
7752                serde_json::json!({"action": "manage", "manage_action": "close"}),
7753            ),
7754            (
7755                "window",
7756                serde_json::json!({"action": "set_title", "title": "x"}),
7757            ),
7758            (
7759                "navigate",
7760                serde_json::json!({"action": "go_to", "url": "https://e.com"}),
7761            ),
7762            (
7763                "css",
7764                serde_json::json!({"action": "inject", "css": "body{}"}),
7765            ),
7766            ("route", serde_json::json!({"action": "clear_all"})),
7767            ("recording", serde_json::json!({"action": "start"})),
7768            ("recording", serde_json::json!({"action": "replay"})),
7769            ("logs", serde_json::json!({"action": "clear"})),
7770            (
7771                "fault",
7772                serde_json::json!({"action": "inject", "command": "x", "fault_type": "error"}),
7773            ),
7774            (
7775                "introspect",
7776                serde_json::json!({"action": "command_timings"}),
7777            ),
7778        ];
7779        for (tool, args) in blocked {
7780            let r = call(&h, tool, args.clone()).await;
7781            assert!(
7782                is_privacy_blocked(&r),
7783                "Observe must block {tool} {args} at dispatch, got: {:?}",
7784                r.content
7785            );
7786        }
7787    }
7788
7789    #[tokio::test]
7790    async fn observe_allows_read_only_through_dispatch() {
7791        let h = handler(crate::privacy::observe_privacy_config());
7792        // These reads must NOT be privacy-blocked (they may fail for other reasons
7793        // against the rejecting bridge, but never with a privacy block).
7794        let allowed: &[(&str, serde_json::Value)] = &[
7795            ("get_registry", serde_json::json!({})),
7796            ("get_memory_stats", serde_json::json!({})),
7797            ("window", serde_json::json!({"action": "list"})),
7798            ("logs", serde_json::json!({"action": "ipc"})),
7799            (
7800                "inspect",
7801                serde_json::json!({"action": "get_styles", "ref_id": "e1"}),
7802            ),
7803        ];
7804        for (tool, args) in allowed {
7805            let r = call(&h, tool, args.clone()).await;
7806            assert!(
7807                !is_privacy_blocked(&r),
7808                "Observe must allow {tool} {args} at dispatch (blocked unexpectedly)"
7809            );
7810        }
7811    }
7812
7813    // ── Test profile: interactions allowed, eval/replay/route blocked ─────────
7814
7815    #[tokio::test]
7816    async fn test_profile_dispatch_boundaries() {
7817        let h = handler(crate::privacy::test_privacy_config());
7818        // Allowed in Test:
7819        for (tool, args) in [
7820            (
7821                "interact",
7822                serde_json::json!({"action": "click", "ref_id": "e1"}),
7823            ),
7824            (
7825                "input",
7826                serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
7827            ),
7828            (
7829                "storage",
7830                serde_json::json!({"action": "set", "key": "k", "value": "v"}),
7831            ),
7832            ("navigate", serde_json::json!({"action": "go_back"})),
7833            ("recording", serde_json::json!({"action": "start"})),
7834            ("logs", serde_json::json!({"action": "clear"})),
7835        ] {
7836            let r = call(&h, tool, args.clone()).await;
7837            assert!(!is_privacy_blocked(&r), "Test must allow {tool} {args}");
7838        }
7839        // Blocked in Test (arbitrary eval, navigation mutation, replay, FullControl tools):
7840        for (tool, args) in [
7841            ("eval_js", serde_json::json!({"code": "1"})),
7842            (
7843                "wait_for",
7844                serde_json::json!({"condition": "expression", "value": "true"}),
7845            ),
7846            ("verify_state", serde_json::json!({"frontend_expr": "1"})),
7847            (
7848                "navigate",
7849                serde_json::json!({"action": "go_to", "url": "https://e.com"}),
7850            ),
7851            ("recording", serde_json::json!({"action": "replay"})),
7852            (
7853                "route",
7854                serde_json::json!({"action": "add", "pattern": "x"}),
7855            ),
7856            ("css", serde_json::json!({"action": "inject", "css": "x"})),
7857            (
7858                "window",
7859                serde_json::json!({"action": "set_title", "title": "x"}),
7860            ),
7861        ] {
7862            let r = call(&h, tool, args.clone()).await;
7863            assert!(is_privacy_blocked(&r), "Test must block {tool} {args}");
7864        }
7865    }
7866
7867    // ── disabled_tools: bare-name disable covers all of a compound tool's
7868    //    actions, and per-action disable is honored even when the handler
7869    //    historically did not check it (the route.clear bypass). ──────────────
7870
7871    #[tokio::test]
7872    async fn disabling_bare_compound_tool_blocks_all_actions() {
7873        let cfg = PrivacyConfig {
7874            disabled_tools: HashSet::from(["recording".to_string()]),
7875            ..Default::default()
7876        }; // FullControl with the whole `recording` tool disabled
7877        let h = handler(cfg);
7878        for action in ["start", "stop", "replay", "import", "export"] {
7879            let r = call(&h, "recording", serde_json::json!({"action": action})).await;
7880            assert!(
7881                is_privacy_blocked(&r),
7882                "disabling bare `recording` must block recording.{action}"
7883            );
7884        }
7885    }
7886
7887    #[tokio::test]
7888    async fn disabling_specific_action_is_honored_at_dispatch() {
7889        // The historical bypass: `route.clear`'s handler had no per-action check,
7890        // so a `disabled_tools` entry for it was silently ignored. The central
7891        // gate now enforces it.
7892        let cfg = PrivacyConfig {
7893            disabled_tools: HashSet::from([
7894                "route.clear".to_string(),
7895                "route.clear_all".to_string(),
7896            ]),
7897            ..Default::default()
7898        }; // FullControl: everything else allowed
7899        let h = handler(cfg);
7900
7901        let blocked = call(&h, "route", serde_json::json!({"action": "clear", "id": 1})).await;
7902        assert!(is_privacy_blocked(&blocked), "route.clear must be blocked");
7903        let blocked_all = call(&h, "route", serde_json::json!({"action": "clear_all"})).await;
7904        assert!(
7905            is_privacy_blocked(&blocked_all),
7906            "route.clear_all must be blocked"
7907        );
7908
7909        // A sibling action the operator did NOT disable is still reachable.
7910        let allowed = call(&h, "route", serde_json::json!({"action": "list"})).await;
7911        assert!(
7912            !is_privacy_blocked(&allowed),
7913            "route.list must remain allowed"
7914        );
7915    }
7916
7917    /// R4-NET2: `animation scrub` with `capture=true` takes native window screenshots, so an
7918    /// operator who disabled `screenshot` must not get pixels through it (`trace` already
7919    /// required both). Without capture, scrub is a plain page read and stays allowed.
7920    #[tokio::test]
7921    async fn animation_scrub_capture_requires_the_screenshot_tool() {
7922        let cfg = PrivacyConfig {
7923            disabled_tools: HashSet::from(["screenshot".to_string()]),
7924            ..Default::default()
7925        };
7926        let h = handler(cfg);
7927        let r = call(
7928            &h,
7929            "animation",
7930            serde_json::json!({"action": "scrub", "selector": "#toast", "capture": true}),
7931        )
7932        .await;
7933        assert!(
7934            is_privacy_blocked(&r),
7935            "scrub capture must be refused while `screenshot` is disabled, got: {:?}",
7936            r.content
7937        );
7938        assert!(
7939            r.content
7940                .iter()
7941                .any(|c| matches!(c, ContentBlock::Text(t) if t.text.contains("screenshot"))),
7942            "the refusal must name the screenshot tool: {:?}",
7943            r.content
7944        );
7945        let r = call(
7946            &h,
7947            "animation",
7948            serde_json::json!({"action": "scrub", "selector": "#toast"}),
7949        )
7950        .await;
7951        assert!(!is_privacy_blocked(&r), "scrub without capture is allowed");
7952    }
7953
7954    /// R4-NET4: the action spelling of an `inspect` capability disables it at dispatch.
7955    #[tokio::test]
7956    async fn disabling_an_inspect_action_by_its_action_name_is_honored() {
7957        let cfg = PrivacyConfig {
7958            disabled_tools: HashSet::from(["inspect.get_styles".to_string()]),
7959            ..Default::default()
7960        };
7961        let h = handler(cfg);
7962        let r = call(
7963            &h,
7964            "inspect",
7965            serde_json::json!({"action": "get_styles", "ref_id": "e1"}),
7966        )
7967        .await;
7968        assert!(
7969            is_privacy_blocked(&r),
7970            "inspect.get_styles must be blocked, got: {:?}",
7971            r.content
7972        );
7973        let r = call(
7974            &h,
7975            "inspect",
7976            serde_json::json!({"action": "get_bounding_boxes", "ref_ids": ["e1"]}),
7977        )
7978        .await;
7979        assert!(!is_privacy_blocked(&r), "a sibling action stays allowed");
7980    }
7981
7982    // Command-policy enforcement on invoke paths (A1/A2) and resource gating (B1)
7983    // are covered with side-effect detection (a bridge that records actual invokes)
7984    // in the `command_policy_dispatch_tests` module below — that proves the blocked
7985    // command never reaches the bridge, not merely that an error string is returned.
7986
7987    #[tokio::test]
7988    async fn full_control_allows_everything_at_dispatch() {
7989        let h = handler(PrivacyConfig::default());
7990        for (tool, args) in [
7991            ("recording", serde_json::json!({"action": "replay"})),
7992            ("route", serde_json::json!({"action": "clear_all"})),
7993            ("eval_js", serde_json::json!({"code": "1"})),
7994            ("fault", serde_json::json!({"action": "list"})),
7995        ] {
7996            let r = call(&h, tool, args.clone()).await;
7997            assert!(
7998                !is_privacy_blocked(&r),
7999                "FullControl must allow {tool} {args}"
8000            );
8001        }
8002    }
8003}
8004
8005/// Command-policy enforcement on EVERY command-invoking path (audit #30/#31, triage A1/A2).
8006///
8007/// The prior privacy suite validated the permission-string matrix — `is_tool_enabled("x")`
8008/// in isolation — which let structural dispatch bypasses pass undetected (the audit's
8009/// central criticism: "tests validate the STRING MATRIX, not actual dispatch behavior").
8010///
8011/// These tests instead drive the REAL dispatcher with a bridge that records every script
8012/// handed to `eval_webview`, and assert the dangerous **side effect** — the
8013/// `__TAURI_INTERNALS__.invoke(<command>)` script — is NEVER emitted when the command is on
8014/// the operator's blocklist, on each path that invokes commands OUTSIDE `invoke_command`:
8015/// `recording.replay`, `recording.import` + `replay`, `introspect.contract_record`, and
8016/// `introspect.contract_check`. Each has a positive control proving an *allowed* command IS
8017/// invoked (so a blanket-block can't make the negative test pass vacuously).
8018#[cfg(test)]
8019mod command_policy_dispatch_tests {
8020    use super::*;
8021    use crate::bridge::WebviewBridge;
8022    use crate::privacy::PrivacyConfig;
8023    use serde_json::json;
8024    use std::collections::{HashMap, HashSet};
8025    use std::sync::Mutex as StdMutex;
8026    use victauri_core::{
8027        AppEvent, CommandRegistry, EventLog, EventRecorder, IpcCall, IpcResult, RecordedEvent,
8028        RecordedSession, WindowState,
8029    };
8030
8031    /// A bridge that RECORDS every script passed to `eval_webview` (so a test can assert a
8032    /// blocklisted command's invoke was never emitted) then fails the eval fast — an allowed
8033    /// command is observably *attempted* without hanging on a callback that never arrives.
8034    ///
8035    /// When constructed via [`RecordingBridge::answering`] it also resolves the pre-eval
8036    /// liveness probe, simulating a healthy webview so an ALLOWED command's invoke actually
8037    /// reaches the bridge. Default-constructed bridges leave the probe unanswered — which is
8038    /// fine for negative tests, since a blocked command is rejected at the privacy gate
8039    /// *before* any eval (and thus never probes).
8040    #[derive(Clone, Default)]
8041    struct RecordingBridge {
8042        scripts: Arc<StdMutex<Vec<String>>>,
8043        pending_evals: Option<crate::PendingCallbacks>,
8044        /// The nonce of the page currently loaded (reported by the liveness probe).
8045        page_nonce: Arc<StdMutex<Option<String>>>,
8046        /// When set, the eval wrapper script is answered with this callback body.
8047        eval_answer: Arc<StdMutex<Option<String>>>,
8048        /// Every trusted (OS-level) input delivered, e.g. `click 50,26` / `type hi` / `key Enter`.
8049        natives: Arc<StdMutex<Vec<String>>>,
8050        /// While set, the liveness probe is never answered (a busy or wedged UI thread).
8051        probe_silent: Arc<AtomicBool>,
8052    }
8053
8054    /// Extract the 36-char eval id from a probe script of the form `…id:"<uuid>"…`.
8055    fn extract_probe_id(script: &str) -> Option<String> {
8056        let start = script.find("id:\"")? + 4;
8057        script.get(start..start + 36).map(str::to_string)
8058    }
8059
8060    /// Extract the eval id from the eval wrapper script (`const __vic = { id: "<uuid>", …`).
8061    fn extract_wrapper_id(script: &str) -> Option<String> {
8062        let start = script.find("__vic = { id: \"")? + 15;
8063        script.get(start..start + 36).map(str::to_string)
8064    }
8065
8066    impl RecordingBridge {
8067        /// A recording bridge that answers the liveness probe with the state's pending-evals
8068        /// map, so a permitted command's eval proceeds past the probe and is observably
8069        /// injected.
8070        fn answering(pending_evals: crate::PendingCallbacks) -> Self {
8071            Self {
8072                pending_evals: Some(pending_evals),
8073                ..Self::default()
8074            }
8075        }
8076
8077        /// Like [`answering`](Self::answering), in a page whose nonce is `nonce`.
8078        fn in_page(pending_evals: crate::PendingCallbacks, nonce: &str) -> Self {
8079            let b = Self::answering(pending_evals);
8080            b.load_page(nonce);
8081            b
8082        }
8083
8084        /// The window now shows a page with this nonce (what the liveness probe reports).
8085        fn load_page(&self, nonce: &str) {
8086            *self
8087                .page_nonce
8088                .lock()
8089                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(nonce.to_string());
8090        }
8091
8092        fn answer_evals_with(&self, body: &str) {
8093            *self
8094                .eval_answer
8095                .lock()
8096                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(body.to_string());
8097        }
8098
8099        fn natives(&self) -> Vec<String> {
8100            self.natives
8101                .lock()
8102                .unwrap_or_else(std::sync::PoisonError::into_inner)
8103                .clone()
8104        }
8105
8106        fn record_native(&self, what: String) -> Result<(), String> {
8107            self.natives
8108                .lock()
8109                .unwrap_or_else(std::sync::PoisonError::into_inner)
8110                .push(what);
8111            Ok(())
8112        }
8113
8114        /// True iff any recorded eval script invoked `command` via the Tauri IPC bridge.
8115        fn invoked(&self, command: &str) -> bool {
8116            let needle = format!("invoke({}", js_string(command));
8117            self.scripts
8118                .lock()
8119                .unwrap_or_else(std::sync::PoisonError::into_inner)
8120                .iter()
8121                .any(|s| s.contains(&needle))
8122        }
8123    }
8124
8125    impl WebviewBridge for RecordingBridge {
8126        fn eval_webview(&self, _label: Option<&str>, script: &str) -> Result<(), String> {
8127            self.scripts
8128                .lock()
8129                .unwrap_or_else(std::sync::PoisonError::into_inner)
8130                .push(script.to_string());
8131            // If wired with a pending-evals map, answer the pre-eval liveness probe
8132            // (simulating a healthy webview) so the real eval proceeds past it. The
8133            // real eval is still left unanswered, so it times out fast at the 100ms
8134            // test `eval_timeout` — we only care WHICH scripts reached the bridge,
8135            // never the eval's return value.
8136            let answer = if script.contains("probe_ok") {
8137                if self.probe_silent.load(Ordering::SeqCst) {
8138                    return Ok(());
8139                }
8140                extract_probe_id(script).map(|id| {
8141                    let nonce = self
8142                        .page_nonce
8143                        .lock()
8144                        .unwrap_or_else(std::sync::PoisonError::into_inner)
8145                        .clone();
8146                    let body = nonce.map_or_else(
8147                        || "\"probe_ok\"".to_string(),
8148                        |n| format!("\"probe_ok:{n}\""),
8149                    );
8150                    (id, body)
8151                })
8152            } else {
8153                let body = self
8154                    .eval_answer
8155                    .lock()
8156                    .unwrap_or_else(std::sync::PoisonError::into_inner)
8157                    .clone();
8158                extract_wrapper_id(script).zip(body)
8159            };
8160            if let (Some(pending), Some((id, body))) = (&self.pending_evals, answer) {
8161                let pending = pending.clone();
8162                std::thread::spawn(move || {
8163                    let mut map = pending.blocking_lock();
8164                    if let Some(tx) = map.remove(&id) {
8165                        let _ = tx.send(body);
8166                    }
8167                });
8168            }
8169            // Return Ok so `eval_with_return` injects BOTH its watchdog and the
8170            // user-code script (it bails on the first Err).
8171            Ok(())
8172        }
8173        fn native_click(&self, _l: Option<&str>, x: f64, y: f64) -> Result<(), String> {
8174            self.record_native(format!("click {x},{y}"))
8175        }
8176        fn native_type_text(&self, _l: Option<&str>, text: &str) -> Result<(), String> {
8177            self.record_native(format!("type {text}"))
8178        }
8179        fn native_key(&self, _l: Option<&str>, key: &str) -> Result<(), String> {
8180            self.record_native(format!("key {key}"))
8181        }
8182        fn get_window_states(&self, _l: Option<&str>) -> Vec<WindowState> {
8183            Vec::new()
8184        }
8185        fn list_window_labels(&self) -> Vec<String> {
8186            Vec::new()
8187        }
8188        fn get_native_handle(&self, _l: Option<&str>) -> Result<isize, String> {
8189            Err("no handle".to_string())
8190        }
8191        fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
8192            Err("no window".to_string())
8193        }
8194        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
8195            Ok(())
8196        }
8197        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
8198            Ok(())
8199        }
8200        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
8201            Ok(())
8202        }
8203    }
8204
8205    fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
8206        Arc::new(VictauriState {
8207            event_log: EventLog::new(1000),
8208            registry: CommandRegistry::new(),
8209            port: std::sync::atomic::AtomicU16::new(0),
8210            pending_evals: Arc::new(Mutex::new(HashMap::new())),
8211            recorder: EventRecorder::new(1000),
8212            privacy,
8213            eval_timeout: std::time::Duration::from_millis(100),
8214            shutdown_tx: tokio::sync::watch::channel(false).0,
8215            started_at: std::time::Instant::now(),
8216            tool_invocations: std::sync::atomic::AtomicU64::new(0),
8217            allow_file_navigation: false,
8218            command_timings: crate::introspection::CommandTimings::new(),
8219            fault_registry: crate::introspection::FaultRegistry::new(),
8220            contract_store: crate::introspection::ContractStore::new(),
8221            startup_timeline: crate::introspection::StartupTimeline::new(),
8222            event_bus: crate::introspection::EventBusMonitor::default(),
8223            task_tracker: crate::introspection::TaskTracker::new(),
8224            bridge_ready: std::sync::atomic::AtomicBool::new(true),
8225            bridge_notify: tokio::sync::Notify::new(),
8226            db_search_paths: Vec::new(),
8227            screencast: Arc::new(crate::screencast::Screencast::default()),
8228            probes: crate::introspection::AppStateProbes::default(),
8229            drain_watermarks: crate::introspection::DrainWatermarks::default(),
8230            page_loads: crate::introspection::PageLoads::default(),
8231        })
8232    }
8233
8234    // FullControl, except the named commands are blocklisted — exactly the scenario
8235    // the audit flagged: an operator who trusts `command_blocklist` to stop a
8236    // dangerous command.
8237    fn blocking(cmds: &[&str]) -> PrivacyConfig {
8238        PrivacyConfig {
8239            command_blocklist: cmds.iter().map(|s| (*s).to_string()).collect(),
8240            ..Default::default()
8241        }
8242    }
8243
8244    fn ipc_event(command: &str) -> AppEvent {
8245        AppEvent::Ipc(IpcCall::new(
8246            format!("c-{command}"),
8247            command.to_string(),
8248            chrono::Utc::now(),
8249            IpcResult::Ok(json!(true)),
8250            Some(1),
8251            0,
8252            "main".to_string(),
8253        ))
8254    }
8255
8256    fn result_text(r: &CallToolResult) -> String {
8257        r.content
8258            .iter()
8259            .filter_map(|c| match c {
8260                ContentBlock::Text(t) => Some(t.text.clone()),
8261                _ => None,
8262            })
8263            .collect::<Vec<_>>()
8264            .join("\n")
8265    }
8266
8267    async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
8268        match h.execute_tool(tool, args).await {
8269            Ok(r) => r,
8270            Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
8271        }
8272    }
8273
8274    // ── introspect event_bus output cap (VIC-4) ──────────────────────────────
8275    #[tokio::test]
8276    async fn event_bus_caps_output_to_limit() {
8277        // The full buffers can be tens of thousands of events (megabytes); the action must cap
8278        // output (default 100, newest first) and still report the true total + a truncated flag.
8279        use crate::introspection::CapturedTauriEvent;
8280        let state = state_with(PrivacyConfig::default());
8281        for i in 0..150 {
8282            state.event_bus.push(CapturedTauriEvent {
8283                name: format!("evt-{i}"),
8284                payload: "{}".to_string(),
8285                timestamp: chrono::Utc::now().to_rfc3339(),
8286            });
8287        }
8288        let h = VictauriMcpHandler::new(state, Arc::new(RecordingBridge::default()));
8289
8290        // Default limit (100).
8291        let r = call(&h, "introspect", json!({"action": "event_bus"})).await;
8292        let v: serde_json::Value = serde_json::from_str(&result_text(&r)).unwrap();
8293        assert_eq!(
8294            v["tauri_events"]["count"], 150,
8295            "true total must be reported"
8296        );
8297        assert_eq!(v["tauri_events"]["returned"], 100, "default cap is 100");
8298        assert_eq!(v["tauri_events"]["truncated"], true);
8299        assert_eq!(v["tauri_events"]["events"].as_array().unwrap().len(), 100);
8300
8301        // Explicit smaller limit (passed via the generic `args` object).
8302        let r = call(
8303            &h,
8304            "introspect",
8305            json!({"action": "event_bus", "args": {"limit": 10}}),
8306        )
8307        .await;
8308        let v: serde_json::Value = serde_json::from_str(&result_text(&r)).unwrap();
8309        assert_eq!(v["tauri_events"]["returned"], 10);
8310        assert_eq!(v["tauri_events"]["events"].as_array().unwrap().len(), 10);
8311    }
8312
8313    // ── recording.replay (audit #30/#31, A1) ─────────────────────────────────
8314
8315    #[tokio::test]
8316    async fn replay_never_invokes_a_blocklisted_command() {
8317        let bridge = RecordingBridge::default();
8318        let state = state_with(blocking(&["delete_account"]));
8319        state.recorder.start("s1".to_string()).unwrap();
8320        state.recorder.record_event(ipc_event("delete_account"));
8321        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8322
8323        let r = call(&h, "recording", json!({"action": "replay"})).await;
8324
8325        assert!(
8326            !bridge.invoked("delete_account"),
8327            "SIDE-EFFECT LEAK: replay handed a blocklisted command's invoke to the bridge (audit #30/#31)"
8328        );
8329        assert!(
8330            result_text(&r).contains("blocked"),
8331            "replay should report the command as blocked, got: {}",
8332            result_text(&r)
8333        );
8334    }
8335
8336    #[tokio::test]
8337    async fn replay_does_invoke_an_allowed_command() {
8338        // Positive control: proves the negative test isn't vacuous (the path really
8339        // reaches the bridge for a permitted command).
8340        let state = state_with(PrivacyConfig::default());
8341        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8342        state.recorder.start("s1".to_string()).unwrap();
8343        state.recorder.record_event(ipc_event("greet"));
8344        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8345
8346        let _ = call(&h, "recording", json!({"action": "replay"})).await;
8347
8348        assert!(
8349            bridge.invoked("greet"),
8350            "positive control failed: an ALLOWED command was not invoked, so the negative test proves nothing"
8351        );
8352    }
8353
8354    #[tokio::test]
8355    async fn imported_session_cannot_invoke_a_blocklisted_command() {
8356        // audit #31: a crafted session handed to an agent ("replay this to reproduce")
8357        // must not become arbitrary command invocation.
8358        let bridge = RecordingBridge::default();
8359        let state = state_with(blocking(&["wipe_database"]));
8360        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8361
8362        let session = RecordedSession::new(
8363            "poisoned".to_string(),
8364            chrono::Utc::now(),
8365            vec![RecordedEvent::new(
8366                0,
8367                chrono::Utc::now(),
8368                ipc_event("wipe_database"),
8369            )],
8370            Vec::new(),
8371        );
8372        let session_json = serde_json::to_string(&session).unwrap();
8373
8374        let imp = call(
8375            &h,
8376            "recording",
8377            json!({"action": "import", "session_json": session_json}),
8378        )
8379        .await;
8380        assert_ne!(
8381            imp.is_error,
8382            Some(true),
8383            "import itself should succeed: {}",
8384            result_text(&imp)
8385        );
8386
8387        let r = call(&h, "recording", json!({"action": "replay"})).await;
8388        assert!(
8389            !bridge.invoked("wipe_database"),
8390            "SIDE-EFFECT LEAK: an imported session replayed a blocklisted command (audit #31)"
8391        );
8392        assert!(result_text(&r).contains("blocked"));
8393    }
8394
8395    #[tokio::test]
8396    async fn replay_skips_calls_it_cannot_reproduce_faithfully() {
8397        // Recordings do not capture arguments: re-invoking `delete_todo({id:3})` as
8398        // `delete_todo()` is guaranteed wrong, and re-running a failed/pending call reproduces
8399        // nothing. Only successful no-arg calls are replayed.
8400        let state = state_with(PrivacyConfig::default());
8401        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8402        state.recorder.start("s".to_string()).unwrap();
8403        let AppEvent::Ipc(mut with_args) = ipc_event("delete_todo") else {
8404            unreachable!()
8405        };
8406        with_args.arg_size_bytes = 8;
8407        state.recorder.record_event(AppEvent::Ipc(with_args));
8408        let AppEvent::Ipc(mut failed) = ipc_event("flaky") else {
8409            unreachable!()
8410        };
8411        failed.result = IpcResult::Err("boom".to_string());
8412        state.recorder.record_event(AppEvent::Ipc(failed));
8413        state.recorder.record_event(ipc_event("get_counter"));
8414        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8415
8416        let r = call(&h, "recording", json!({"action": "replay"})).await;
8417        let text = result_text(&r);
8418        assert!(
8419            !bridge.invoked("delete_todo"),
8420            "a call with args must not be replayed"
8421        );
8422        assert!(
8423            !bridge.invoked("flaky"),
8424            "a failed call must not be replayed"
8425        );
8426        assert!(
8427            bridge.invoked("get_counter"),
8428            "a successful no-arg call is replayed"
8429        );
8430        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
8431        assert_eq!(v["skipped"], 2, "{text}");
8432        assert_eq!(v["replayed"], 1, "{text}");
8433    }
8434
8435    /// A state whose eval timeout is long enough that only the window/shutdown watch can end
8436    /// an unanswered eval quickly.
8437    fn slow_eval_state() -> Arc<VictauriState> {
8438        let Ok(mut s) = Arc::try_unwrap(state_with(PrivacyConfig::default())) else {
8439            unreachable!("fresh Arc has one owner")
8440        };
8441        s.eval_timeout = std::time::Duration::from_secs(20);
8442        Arc::new(s)
8443    }
8444
8445    #[tokio::test]
8446    async fn eval_reports_a_window_closed_mid_call_instead_of_timing_out() {
8447        // The RecordingBridge answers the liveness probe, never the user code, and lists NO
8448        // windows — i.e. the target window is gone while the call is in flight (what a
8449        // command that closes its own window produces).
8450        let state = slow_eval_state();
8451        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8452        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
8453        let started = std::time::Instant::now();
8454        let r = call(
8455            &h,
8456            "eval_js",
8457            json!({"code": "await new Promise(() => {})", "webview_label": "popup"}),
8458        )
8459        .await;
8460        let text = result_text(&r);
8461        assert_eq!(r.is_error, Some(true), "{text}");
8462        assert!(
8463            text.contains("was closed while the call was in flight"),
8464            "{text}"
8465        );
8466        assert!(
8467            started.elapsed() < std::time::Duration::from_secs(5),
8468            "must not wait out the 20s eval timeout (took {:?})",
8469            started.elapsed()
8470        );
8471    }
8472
8473    /// A bridge whose UI thread never answers a window listing (a busy/wedged UI), but which
8474    /// otherwise behaves like `RecordingBridge`.
8475    struct WedgedListingBridge(RecordingBridge);
8476
8477    impl WebviewBridge for WedgedListingBridge {
8478        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
8479            self.0.eval_webview(label, script)
8480        }
8481        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
8482            self.0.get_window_states(label)
8483        }
8484        fn list_window_labels(&self) -> Vec<String> {
8485            Vec::new()
8486        }
8487        fn try_list_window_labels(&self) -> Result<Vec<String>, String> {
8488            Err("list_window_labels did not complete on the main thread: timed out".to_string())
8489        }
8490        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
8491            self.0.get_native_handle(label)
8492        }
8493        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
8494            self.0.manage_window(label, action)
8495        }
8496        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
8497            self.0.resize_window(label, w, h)
8498        }
8499        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
8500            self.0.move_window(label, x, y)
8501        }
8502        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
8503            self.0.set_window_title(label, title)
8504        }
8505    }
8506
8507    #[tokio::test]
8508    async fn page_originated_evals_cannot_starve_agent_evals() {
8509        // Red-team: page JS can call victauri_eval_js; with one shared pool it could park
8510        // never-resolving evals in all 100 slots and fail every agent eval.
8511        let state = state_with(PrivacyConfig::default());
8512        let mut held = Vec::new();
8513        for i in 0..crate::tools::MAX_PAGE_PENDING_EVALS {
8514            let (tx, rx) = tokio::sync::oneshot::channel();
8515            // Spread over windows so the page-wide budget, not a window's, is what fills up.
8516            let label = format!("w{}", i % 5);
8517            let slot = crate::tools::reserve_page_eval(&state, &label, tx)
8518                .await
8519                .unwrap();
8520            held.push((slot, rx));
8521        }
8522        let (tx, _rx) = tokio::sync::oneshot::channel();
8523        let over = crate::tools::reserve_page_eval(&state, "w9", tx).await;
8524        assert!(over.err().unwrap().contains("page-originated"));
8525        // The agent path still has room.
8526        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8527        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
8528        let (tx, _rx2) = tokio::sync::oneshot::channel();
8529        assert!(h.reserve_pending("agent-1", tx).await.is_ok());
8530        drop(held);
8531    }
8532
8533    #[tokio::test]
8534    async fn one_window_cannot_starve_another_windows_page_evals() {
8535        let state = state_with(PrivacyConfig::default());
8536        let mut held = Vec::new();
8537        let mut refused = None;
8538        // Window "a" (whose label is a prefix of "a:b") fills its own budget and no more.
8539        for _ in 0..=crate::tools::MAX_PAGE_PENDING_EVALS_PER_WINDOW {
8540            let (tx, rx) = tokio::sync::oneshot::channel();
8541            match crate::tools::reserve_page_eval(&state, "a", tx).await {
8542                Ok(slot) => held.push((slot, rx)),
8543                Err(e) => refused = Some(e),
8544            }
8545        }
8546        assert_eq!(held.len(), crate::tools::MAX_PAGE_PENDING_EVALS_PER_WINDOW);
8547        assert!(refused.unwrap().contains("window 'a'"));
8548        let (tx, _rx) = tokio::sync::oneshot::channel();
8549        let other = crate::tools::reserve_page_eval(&state, "a:b", tx).await;
8550        assert!(
8551            other.is_ok(),
8552            "window 'a:b' was starved by window 'a': {:?}",
8553            other.as_ref().err()
8554        );
8555        // Dropping the slots releases them.
8556        drop(held);
8557        drop(other);
8558        assert!(state.pending_evals.lock().await.is_empty());
8559    }
8560
8561    #[tokio::test]
8562    async fn a_busy_ui_is_not_mistaken_for_a_closed_window() {
8563        // Red-team finding: a main-thread listing that times out returned `[]`, which the
8564        // eval watch read as "window closed … it most likely ran — do not re-run it". A
8565        // listing that FAILS is not evidence; the eval must run to its own timeout instead.
8566        let Ok(mut s) = Arc::try_unwrap(state_with(PrivacyConfig::default())) else {
8567            unreachable!("fresh Arc has one owner")
8568        };
8569        s.eval_timeout = std::time::Duration::from_millis(2500);
8570        let state = Arc::new(s);
8571        let bridge = WedgedListingBridge(RecordingBridge::answering(state.pending_evals.clone()));
8572        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
8573        let r = call(
8574            &h,
8575            "eval_js",
8576            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8577        )
8578        .await;
8579        let text = result_text(&r);
8580        assert!(
8581            !text.contains("was closed"),
8582            "false 'window closed': {text}"
8583        );
8584        assert!(text.contains("timed out"), "{text}");
8585    }
8586
8587    /// `RecordingBridge`, but reporting a live `main` window (so only page loads are in play).
8588    struct MainWindowBridge(RecordingBridge);
8589
8590    impl WebviewBridge for MainWindowBridge {
8591        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
8592            self.0.eval_webview(label, script)
8593        }
8594        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
8595            self.0.get_window_states(label)
8596        }
8597        fn list_window_labels(&self) -> Vec<String> {
8598            vec!["main".to_string()]
8599        }
8600        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
8601            self.0.get_native_handle(label)
8602        }
8603        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
8604            self.0.manage_window(label, action)
8605        }
8606        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
8607            self.0.resize_window(label, w, h)
8608        }
8609        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
8610            self.0.move_window(label, x, y)
8611        }
8612        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
8613            self.0.set_window_title(label, title)
8614        }
8615    }
8616
8617    fn eval_state_with_timeout(ms: u64) -> Arc<VictauriState> {
8618        let Ok(mut s) = Arc::try_unwrap(state_with(PrivacyConfig::default())) else {
8619            unreachable!("fresh Arc has one owner")
8620        };
8621        s.eval_timeout = std::time::Duration::from_millis(ms);
8622        Arc::new(s)
8623    }
8624
8625    #[tokio::test]
8626    async fn eval_fails_fast_when_its_page_reloads() {
8627        let state = eval_state_with_timeout(20_000);
8628        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8629        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
8630        let reloader = state.clone();
8631        tokio::spawn(async move {
8632            tokio::time::sleep(std::time::Duration::from_millis(600)).await;
8633            inner.load_page("page-2"); // the page reloaded
8634            ready_signal(&reloader, "main", "page-2");
8635        });
8636        let started = std::time::Instant::now();
8637        let r = call(
8638            &h,
8639            "eval_js",
8640            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8641        )
8642        .await;
8643        let text = result_text(&r);
8644        assert!(text.contains("loaded a new page"), "{text}");
8645        assert!(
8646            started.elapsed() < std::time::Duration::from_secs(3),
8647            "must not wait out the 20s timeout (took {:?})",
8648            started.elapsed()
8649        );
8650        assert!(
8651            state.pending_evals.lock().await.is_empty(),
8652            "pending entry removed"
8653        );
8654    }
8655
8656    #[tokio::test]
8657    async fn a_late_ready_signal_from_the_same_page_is_not_a_reload() {
8658        // The bridge's ready signal is fire-and-forget at init and can land just after our
8659        // probe; it carries the nonce of the page the eval runs in and is not a reload.
8660        let state = eval_state_with_timeout(1_500);
8661        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8662        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner)));
8663        let late = state.clone();
8664        tokio::spawn(async move {
8665            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8666            ready_signal(&late, "main", "page-1");
8667        });
8668        let r = call(
8669            &h,
8670            "eval_js",
8671            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8672        )
8673        .await;
8674        let text = result_text(&r);
8675        assert!(!text.contains("loaded a new page"), "false reload: {text}");
8676        assert!(text.contains("timed out"), "{text}");
8677    }
8678
8679    #[tokio::test]
8680    async fn invoke_command_honors_timeout_ms_and_skips_aborted_timings() {
8681        let state = eval_state_with_timeout(20_000);
8682        let bridge = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone()));
8683        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
8684        let started = std::time::Instant::now();
8685        let r = call(
8686            &h,
8687            "invoke_command",
8688            json!({"command": "slow_thing", "webview_label": "main", "timeout_ms": 400}),
8689        )
8690        .await;
8691        let text = result_text(&r);
8692        assert!(
8693            started.elapsed() < std::time::Duration::from_secs(3),
8694            "timeout_ms ignored"
8695        );
8696        assert!(text.contains("timed out after 400ms"), "{text}");
8697        assert!(
8698            text.contains("timeout_ms"),
8699            "the error must point at timeout_ms: {text}"
8700        );
8701        assert!(
8702            state.command_timings.stats_for("slow_thing").is_none(),
8703            "a timed-out call is not a command duration"
8704        );
8705    }
8706
8707    #[tokio::test]
8708    async fn eval_reports_app_shutdown_instead_of_timing_out() {
8709        let state = slow_eval_state();
8710        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8711        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
8712        let shutdown = state.clone();
8713        tokio::spawn(async move {
8714            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
8715            let _ = shutdown.shutdown_tx.send(true);
8716        });
8717        let r = call(
8718            &h,
8719            "eval_js",
8720            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8721        )
8722        .await;
8723        let text = result_text(&r);
8724        assert_eq!(r.is_error, Some(true), "{text}");
8725        assert!(text.contains("shutting down"), "{text}");
8726        assert!(
8727            state.pending_evals.lock().await.is_empty(),
8728            "pending entry removed"
8729        );
8730    }
8731
8732    /// The bridge's ready signal for window `label`, from a page whose nonce is `nonce`.
8733    fn ready_signal(state: &VictauriState, label: &str, nonce: &str) {
8734        state.page_loads.record_load(label, Some(nonce));
8735    }
8736
8737    async fn hanging_eval(h: &VictauriMcpHandler) -> (String, std::time::Duration) {
8738        let started = std::time::Instant::now();
8739        let r = call(
8740            h,
8741            "eval_js",
8742            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8743        )
8744        .await;
8745        (result_text(&r), started.elapsed())
8746    }
8747
8748    #[tokio::test]
8749    async fn a_reload_right_after_injection_is_detected() {
8750        // A reload that completes within a few ms of the injection (eval_js("location.reload()"),
8751        // navigate, a fast HMR) was hidden by a 250ms grace and waited out the full timeout.
8752        let state = eval_state_with_timeout(20_000);
8753        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8754        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
8755        let reloader = state.clone();
8756        tokio::spawn(async move {
8757            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8758            inner.load_page("page-2");
8759            ready_signal(&reloader, "main", "page-2");
8760        });
8761        let (text, took) = hanging_eval(&h).await;
8762        assert!(text.contains("loaded a new page"), "{text}");
8763        assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
8764    }
8765
8766    #[tokio::test]
8767    async fn a_same_page_ready_signal_arriving_late_is_not_a_reload() {
8768        // Under load the page's own ready signal can land seconds after the probe; it carries the
8769        // nonce the eval was armed in, so the eval keeps waiting for its real result.
8770        let state = eval_state_with_timeout(3_000);
8771        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8772        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner)));
8773        let late = state.clone();
8774        tokio::spawn(async move {
8775            tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
8776            ready_signal(&late, "main", "page-1");
8777        });
8778        let (text, _) = hanging_eval(&h).await;
8779        assert!(!text.contains("loaded a new page"), "false reload: {text}");
8780        assert!(text.contains("timed out"), "{text}");
8781    }
8782
8783    #[tokio::test]
8784    async fn a_forged_ready_signal_does_not_abort_an_eval() {
8785        // Page script can call victauri_eval_callback('__victauri_bridge_ready__') itself. The
8786        // page did not change (the probe still reports the armed nonce), so it is not a reload.
8787        let state = eval_state_with_timeout(2_500);
8788        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8789        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner)));
8790        let forger = state.clone();
8791        tokio::spawn(async move {
8792            tokio::time::sleep(std::time::Duration::from_millis(600)).await;
8793            ready_signal(&forger, "main", "forged");
8794        });
8795        let (text, _) = hanging_eval(&h).await;
8796        assert!(!text.contains("loaded a new page"), "forged reload: {text}");
8797        assert!(text.contains("timed out"), "{text}");
8798    }
8799
8800    /// R4-EVAL1: a ready signal whose confirming probe FAILS (a busy UI thread, a full slot
8801    /// map) is no evidence of a new page. The eval used to abort as "loaded a new page" — which
8802    /// page script can provoke with a forged signal while the UI is busy, and which invites a
8803    /// retry that runs side-effecting code twice. It must keep waiting (its own timeout bounds
8804    /// it).
8805    #[tokio::test]
8806    async fn an_unconfirmed_ready_signal_does_not_abort_an_eval() {
8807        let state = eval_state_with_timeout(4_000);
8808        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8809        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
8810        let signal = state.clone();
8811        tokio::spawn(async move {
8812            tokio::time::sleep(std::time::Duration::from_millis(300)).await;
8813            inner.probe_silent.store(true, Ordering::SeqCst); // the UI stops answering
8814            ready_signal(&signal, "main", "forged");
8815        });
8816        let (text, _) = hanging_eval(&h).await;
8817        assert!(
8818            !text.contains("loaded a new page"),
8819            "aborted on no evidence: {text}"
8820        );
8821        assert!(text.contains("timed out"), "{text}");
8822    }
8823
8824    /// B-L5: the timeout message rules out a parse error only when the fast parse check was
8825    /// actually armed (the page reported a nonce) and delivered; otherwise it cannot know.
8826    #[tokio::test]
8827    async fn a_timeout_claims_no_parse_error_only_when_the_parse_check_ran() {
8828        let state = eval_state_with_timeout(300);
8829        // No nonce from the probe: the parse check is disabled.
8830        let h = VictauriMcpHandler::new(
8831            state.clone(),
8832            Arc::new(RecordingBridge::answering(state.pending_evals.clone())),
8833        );
8834        let (text, _) = hanging_eval(&h).await;
8835        assert!(text.contains("timed out"), "{text}");
8836        assert!(!text.contains("NOT a parse"), "unfounded claim: {text}");
8837        assert!(text.contains("could not be ruled out"), "{text}");
8838        // Armed and delivered: the claim holds.
8839        let h = VictauriMcpHandler::new(
8840            state.clone(),
8841            Arc::new(RecordingBridge::in_page(state.pending_evals.clone(), "p1")),
8842        );
8843        let (text, _) = hanging_eval(&h).await;
8844        assert!(text.contains("NOT a parse error"), "{text}");
8845        // Armed but the check could not be delivered.
8846        let h = VictauriMcpHandler::new(
8847            state.clone(),
8848            Arc::new(CheckNotDeliveredBridge(RecordingBridge::in_page(
8849                state.pending_evals.clone(),
8850                "p1",
8851            ))),
8852        );
8853        let (text, _) = hanging_eval(&h).await;
8854        assert!(!text.contains("NOT a parse"), "unfounded claim: {text}");
8855    }
8856
8857    /// Delivers everything but the eval's parse-check script.
8858    struct CheckNotDeliveredBridge(RecordingBridge);
8859
8860    impl WebviewBridge for CheckNotDeliveredBridge {
8861        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
8862            if script.contains("_evalCheck") {
8863                return Err("window busy".to_string());
8864            }
8865            self.0.eval_webview(label, script)
8866        }
8867        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
8868            self.0.get_window_states(label)
8869        }
8870        fn list_window_labels(&self) -> Vec<String> {
8871            vec!["main".to_string()]
8872        }
8873        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
8874            self.0.get_native_handle(label)
8875        }
8876        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
8877            self.0.manage_window(label, action)
8878        }
8879        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
8880            self.0.resize_window(label, w, h)
8881        }
8882        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
8883            self.0.move_window(label, x, y)
8884        }
8885        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
8886            self.0.set_window_title(label, title)
8887        }
8888    }
8889
8890    /// A page that answers WITHOUT the armed nonce (the bridge that armed the eval is gone)
8891    /// is positive evidence of a new page.
8892    #[tokio::test]
8893    async fn a_page_without_the_armed_nonce_is_a_reload() {
8894        let state = eval_state_with_timeout(20_000);
8895        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8896        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
8897        let reloader = state.clone();
8898        tokio::spawn(async move {
8899            tokio::time::sleep(std::time::Duration::from_millis(300)).await;
8900            *inner
8901                .page_nonce
8902                .lock()
8903                .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
8904            ready_signal(&reloader, "main", "whatever");
8905        });
8906        let (text, took) = hanging_eval(&h).await;
8907        assert!(text.contains("loaded a new page"), "{text}");
8908        assert!(took < std::time::Duration::from_secs(5), "took {took:?}");
8909    }
8910
8911    #[tokio::test]
8912    async fn an_eval_started_after_app_exit_fails_fast() {
8913        // `subscribe()` marks the current value seen: an eval started after the exit signal
8914        // waited its whole timeout for a change that had already happened.
8915        let state = slow_eval_state();
8916        state.shutdown_tx.send_replace(true);
8917        let bridge = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone()));
8918        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
8919        let (text, took) =
8920            tokio::time::timeout(std::time::Duration::from_secs(10), hanging_eval(&h))
8921                .await
8922                .expect("an eval after app exit must not wait out its 20s timeout");
8923        assert!(text.contains("shutting down"), "{text}");
8924        assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
8925    }
8926
8927    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8928    async fn a_dropped_eval_call_releases_its_pending_slot() {
8929        // REST runs the tool inside the axum future: a client that disconnects or times out
8930        // drops it mid-wait. The pending entry used to leak; 100 of them wedged every eval.
8931        let state = eval_state_with_timeout(20_000);
8932        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8933        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
8934        for _ in 0..3 {
8935            let dropped = tokio::time::timeout(
8936                std::time::Duration::from_millis(300),
8937                h.execute_tool("eval_js", json!({"code": "await new Promise(() => {})"})),
8938            )
8939            .await;
8940            assert!(
8941                dropped.is_err(),
8942                "the call must still be in flight when dropped"
8943            );
8944        }
8945        // The recording drain reserves slots too (its page never answers here). It only reads
8946        // while a recording is active and its drain epoch is set, as `recording start` does.
8947        let generation = state
8948            .recorder
8949            .start_session("slot-release".to_string())
8950            .unwrap();
8951        state.drain_watermarks.reset(0.0, generation);
8952        let bridge: Arc<dyn WebviewBridge> = Arc::new(RecordingBridge::default());
8953        let dropped = tokio::time::timeout(
8954            std::time::Duration::from_millis(300),
8955            drain_window_into_recording(&state, &bridge, "main"),
8956        )
8957        .await;
8958        assert!(
8959            dropped.is_err(),
8960            "the drain must still be in flight when dropped"
8961        );
8962        // Removal on drop may be deferred to a task when the map is momentarily locked.
8963        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
8964        assert!(
8965            state.pending_evals.lock().await.is_empty(),
8966            "a dropped call leaked its pending-eval slot"
8967        );
8968    }
8969
8970    /// Answers the liveness probe, but every other script fails to inject.
8971    struct InjectFailsBridge(RecordingBridge);
8972
8973    impl WebviewBridge for InjectFailsBridge {
8974        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
8975            self.0.eval_webview(label, script)?;
8976            if script.contains("probe_ok") {
8977                Ok(())
8978            } else {
8979                Err("window not found: main".to_string())
8980            }
8981        }
8982        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
8983            self.0.get_window_states(label)
8984        }
8985        fn list_window_labels(&self) -> Vec<String> {
8986            vec!["main".to_string()]
8987        }
8988        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
8989            self.0.get_native_handle(label)
8990        }
8991        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
8992            self.0.manage_window(label, action)
8993        }
8994        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
8995            self.0.resize_window(label, w, h)
8996        }
8997        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
8998            self.0.move_window(label, x, y)
8999        }
9000        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
9001            self.0.set_window_title(label, title)
9002        }
9003    }
9004
9005    #[tokio::test]
9006    async fn invoke_command_records_no_timing_for_calls_that_never_ran() {
9007        // Only a call that reached the command is a command duration: a saturated pending map
9008        // (~0ms), a dead bridge (~2s probe) or a failed injection measure nothing about it.
9009        let state = eval_state_with_timeout(1_000);
9010        let fillers: Vec<_> = (0..MAX_PENDING_EVALS)
9011            .map(|_| tokio::sync::oneshot::channel::<String>())
9012            .collect();
9013        {
9014            let mut p = state.pending_evals.lock().await;
9015            for (i, (tx, _)) in fillers.into_iter().enumerate() {
9016                p.insert(format!("filler-{i}"), tx);
9017            }
9018        }
9019        let answering = RecordingBridge::answering(state.pending_evals.clone());
9020        let h = VictauriMcpHandler::new(state.clone(), Arc::new(answering.clone()));
9021        let r = call(&h, "invoke_command", json!({"command": "saturated"})).await;
9022        assert!(
9023            result_text(&r).contains("too many concurrent"),
9024            "{}",
9025            result_text(&r)
9026        );
9027        state.pending_evals.lock().await.clear();
9028
9029        let dead = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
9030        let r = call(&dead, "invoke_command", json!({"command": "dead_bridge"})).await;
9031        assert!(
9032            result_text(&r).contains("bridge not responding"),
9033            "{}",
9034            result_text(&r)
9035        );
9036
9037        let failing = VictauriMcpHandler::new(
9038            state.clone(),
9039            Arc::new(InjectFailsBridge(RecordingBridge::answering(
9040                state.pending_evals.clone(),
9041            ))),
9042        );
9043        let r = call(
9044            &failing,
9045            "invoke_command",
9046            json!({"command": "not_injected"}),
9047        )
9048        .await;
9049        assert!(
9050            result_text(&r).contains("injection failed"),
9051            "{}",
9052            result_text(&r)
9053        );
9054
9055        for cmd in ["saturated", "dead_bridge", "not_injected"] {
9056            assert!(
9057                state.command_timings.stats_for(cmd).is_none(),
9058                "'{cmd}' never ran but was recorded as a command timing"
9059            );
9060        }
9061
9062        // Code that did not parse never ran either.
9063        answering.answer_evals_with(r#"{"__victauri_not_run":"did not begin executing"}"#);
9064        let r = call(&h, "invoke_command", json!({"command": "never_parsed"})).await;
9065        assert!(
9066            result_text(&r).contains("parse error"),
9067            "{}",
9068            result_text(&r)
9069        );
9070        assert!(state.command_timings.stats_for("never_parsed").is_none());
9071
9072        // Positive control: a call that ran (and threw) IS a timing.
9073        answering.answer_evals_with(r#"{"__victauri_err":"boom"}"#);
9074        let r = call(&h, "invoke_command", json!({"command": "ran_and_threw"})).await;
9075        assert!(result_text(&r).contains("boom"), "{}", result_text(&r));
9076        assert!(state.command_timings.stats_for("ran_and_threw").is_some());
9077    }
9078
9079    #[tokio::test]
9080    async fn a_trusted_key_press_stops_when_its_element_cannot_be_focused() {
9081        // The focus result used to be ignored and the OS key sent regardless — into whatever
9082        // element (or app) held focus.
9083        let state = eval_state_with_timeout(2_000);
9084        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9085        bridge.answer_evals_with(r#"{"__victauri_ok":false,"__victauri_type":"value"}"#);
9086        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9087        let r = call(
9088            &h,
9089            "input",
9090            json!({"action": "press_key", "key": "Enter", "ref_id": "e9", "trusted": true}),
9091        )
9092        .await;
9093        let text = result_text(&r);
9094        assert_eq!(r.is_error, Some(true), "{text}");
9095        assert!(text.contains("not focusable"), "key sent anyway: {text}");
9096    }
9097
9098    /// G-12: `route add` forwarded any `delay_ms` (a u64) to the page, where a delayed request
9099    /// is held that long. It is capped like a `fault` delay, refused before reaching the page.
9100    #[tokio::test]
9101    async fn route_delay_is_capped() {
9102        let state = eval_state_with_timeout(2_000);
9103        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9104        bridge.answer_evals_with(&ok_envelope(&json!({"ok": true, "id": 1})));
9105        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9106        let added = |b: &RecordingBridge| {
9107            b.scripts
9108                .lock()
9109                .unwrap_or_else(std::sync::PoisonError::into_inner)
9110                .iter()
9111                .filter(|s| s.contains("addRoute("))
9112                .count()
9113        };
9114        let r = call(
9115            &h,
9116            "route",
9117            json!({"action": "add", "pattern": "/api", "behavior": "delay",
9118                   "delay_ms": MAX_FAULT_DELAY_MS + 1}),
9119        )
9120        .await;
9121        let text = result_text(&r);
9122        assert_eq!(r.is_error, Some(true), "{text}");
9123        assert!(text.contains("delay_ms"), "{text}");
9124        assert_eq!(added(&bridge), 0, "the rule must not reach the page");
9125        // At the cap it is accepted.
9126        let r = call(
9127            &h,
9128            "route",
9129            json!({"action": "add", "pattern": "/api", "behavior": "delay",
9130                   "delay_ms": MAX_FAULT_DELAY_MS}),
9131        )
9132        .await;
9133        assert_ne!(r.is_error, Some(true), "{}", result_text(&r));
9134        assert_eq!(added(&bridge), 1);
9135    }
9136
9137    /// R5-ANIM1: `animation scrub capture=true` reported `"captured": true` with no filmstrip
9138    /// when no frame could be captured (here: no native window handle), hiding the failure.
9139    #[tokio::test]
9140    async fn animation_scrub_never_claims_a_capture_it_did_not_make() {
9141        let state = eval_state_with_timeout(2_000);
9142        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9143        bridge.answer_evals_with(&ok_envelope(
9144            &json!({"prepared": true, "duration": 100, "anim_count": 1, "t": 0}),
9145        ));
9146        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9147        let r = call(
9148            &h,
9149            "animation",
9150            json!({"action": "scrub", "selector": "#toast", "points": 2, "capture": true}),
9151        )
9152        .await;
9153        let text = result_text(&r);
9154        assert_ne!(
9155            r.is_error,
9156            Some(true),
9157            "the geometry curve is still returned: {text}"
9158        );
9159        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
9160        assert_eq!(v["captured"], false, "{v}");
9161        assert!(v.get("filmstrip").is_none(), "{v}");
9162        assert!(
9163            v["capture_error"]
9164                .as_str()
9165                .is_some_and(|e| e.contains("no handle")),
9166            "the capture failure must be surfaced: {v}"
9167        );
9168        assert_eq!(v["curve"].as_array().map(Vec::len), Some(2), "{v}");
9169    }
9170
9171    /// The eval envelope for a page result `value`.
9172    /// R5-JS5: `limit: 0` means "return at most zero entries". The log JS used `.slice(-0)`
9173    /// (and the bridge treats a falsy limit as "all"), so it returned EVERY entry.
9174    #[tokio::test]
9175    async fn log_limit_zero_returns_no_entries() {
9176        let state = eval_state_with_timeout(2_000);
9177        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9178        bridge.answer_evals_with(&ok_envelope(&json!([{"a": 1}, {"a": 2}])));
9179        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9180        for action in [
9181            "console",
9182            "network",
9183            "ipc",
9184            "navigation",
9185            "dialogs",
9186            "events",
9187        ] {
9188            let r = call(&h, "logs", json!({"action": action, "limit": 0})).await;
9189            let text = result_text(&r);
9190            assert_ne!(r.is_error, Some(true), "{action}: {text}");
9191            let v: serde_json::Value = serde_json::from_str(&text)
9192                .unwrap_or_else(|e| panic!("{action}: not JSON ({e}): {text}"));
9193            assert_eq!(
9194                v,
9195                json!([]),
9196                "logs {action} limit=0 returned entries: {text}"
9197            );
9198        }
9199        let r = call(&h, "route", json!({"action": "matches", "limit": 0})).await;
9200        let text = result_text(&r);
9201        let v: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
9202        assert_eq!(
9203            v,
9204            json!([]),
9205            "route matches limit=0 returned entries: {text}"
9206        );
9207    }
9208
9209    fn ok_envelope(value: &serde_json::Value) -> String {
9210        json!({"__victauri_ok": value, "__victauri_type": "object"}).to_string()
9211    }
9212
9213    /// R4-IN1: trusted typing / key presses go out only when the page confirms focus landed
9214    /// on the element; an element that exists but did not take focus stops the input.
9215    #[tokio::test]
9216    async fn trusted_keys_are_sent_only_when_focus_landed_on_the_element() {
9217        let state = eval_state_with_timeout(2_000);
9218        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9219        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9220        let type_args =
9221            json!({"action": "type_text", "ref_id": "e3", "text": "hi", "trusted": true});
9222        let key_args =
9223            json!({"action": "press_key", "key": "Enter", "ref_id": "e3", "trusted": true});
9224
9225        bridge.answer_evals_with(&ok_envelope(&json!({"found": true, "focused": false})));
9226        for args in [&type_args, &key_args] {
9227            let r = call(&h, "input", args.clone()).await;
9228            let text = result_text(&r);
9229            assert_eq!(r.is_error, Some(true), "{args}: {text}");
9230            assert!(text.contains("focus did not land"), "{args}: {text}");
9231        }
9232        bridge.answer_evals_with(&ok_envelope(&json!({"found": false, "focused": false})));
9233        let r = call(&h, "input", type_args.clone()).await;
9234        assert!(
9235            result_text(&r).contains("ref not found"),
9236            "{}",
9237            result_text(&r)
9238        );
9239        assert!(
9240            bridge.natives().is_empty(),
9241            "keys sent: {:?}",
9242            bridge.natives()
9243        );
9244
9245        // Positive control: confirmed focus → the OS input goes out.
9246        bridge.answer_evals_with(&ok_envelope(&json!({"found": true, "focused": true})));
9247        for args in [&type_args, &key_args] {
9248            let r = call(&h, "input", args.clone()).await;
9249            assert_ne!(r.is_error, Some(true), "{args}: {}", result_text(&r));
9250        }
9251        assert_eq!(bridge.natives(), vec!["type hi", "key Enter"]);
9252    }
9253
9254    /// R4-IN2: a trusted click is sent only at a point the page vouched for, and never at an
9255    /// unusable one.
9256    #[tokio::test]
9257    async fn trusted_click_is_refused_unless_the_page_reports_a_clickable_point() {
9258        let state = eval_state_with_timeout(2_000);
9259        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9260        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9261        let args = json!({"action": "click", "ref_id": "e5", "trusted": true});
9262        for (answer, expect) in [
9263            (
9264                json!({"error": "element is covered at its center point by <div>"}),
9265                "covered",
9266            ),
9267            (json!({"x": -4.0, "y": 10.0}), "unusable click point"),
9268            (json!(null), "ref not found"),
9269        ] {
9270            bridge.answer_evals_with(&ok_envelope(&answer));
9271            let r = call(&h, "interact", args.clone()).await;
9272            let text = result_text(&r);
9273            assert_eq!(r.is_error, Some(true), "{answer}: {text}");
9274            assert!(text.contains(expect), "{answer}: {text}");
9275        }
9276        assert!(
9277            bridge.natives().is_empty(),
9278            "clicked: {:?}",
9279            bridge.natives()
9280        );
9281        bridge.answer_evals_with(&ok_envelope(&json!({"x": 150.0, "y": 226.0})));
9282        let r = call(&h, "interact", args).await;
9283        assert_ne!(r.is_error, Some(true), "{}", result_text(&r));
9284        assert_eq!(bridge.natives(), vec!["click 150,226"]);
9285    }
9286
9287    #[tokio::test]
9288    async fn an_unserializable_result_is_reported_as_code_that_ran() {
9289        // A circular object / BigInt result used to read "JavaScript error: …" although the code
9290        // ran — an agent then re-ran side-effecting code to "fix" it.
9291        let state = eval_state_with_timeout(2_000);
9292        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9293        bridge.answer_evals_with(
9294            r#"{"__victauri_unserializable":"Do not know how to serialize a BigInt"}"#,
9295        );
9296        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9297        let r = call(&h, "eval_js", json!({"code": "return 1n"})).await;
9298        let text = result_text(&r);
9299        assert_eq!(r.is_error, Some(true), "{text}");
9300        assert!(text.contains("the code ran"), "{text}");
9301        assert!(text.contains("BigInt"), "{text}");
9302        assert!(!text.contains("JavaScript error"), "{text}");
9303    }
9304
9305    #[tokio::test]
9306    async fn trace_stop_never_stops_a_recording_it_did_not_start() {
9307        let state = state_with(PrivacyConfig::default());
9308        let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
9309        let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await;
9310        let traced = state
9311            .recorder
9312            .active_session_id()
9313            .expect("trace started a recording");
9314        // The agent ends the trace's recording and starts its own.
9315        let _ = state.recorder.stop();
9316        state.recorder.start("mine".to_string()).unwrap();
9317        let _ = call(&h, "trace", json!({"action": "stop"})).await;
9318        assert_eq!(
9319            state.recorder.active_session_id().as_deref(),
9320            Some("mine"),
9321            "trace stop must not end a recording it did not start (it started {traced})"
9322        );
9323    }
9324
9325    #[tokio::test]
9326    async fn restarting_a_trace_does_not_orphan_its_recording() {
9327        let state = state_with(PrivacyConfig::default());
9328        let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
9329        let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await;
9330        let first = state.recorder.active_session_id().unwrap();
9331        let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await;
9332        let second = state.recorder.active_session_id().unwrap();
9333        assert_ne!(
9334            first, second,
9335            "the first trace's recording was superseded, not orphaned"
9336        );
9337        let _ = call(&h, "trace", json!({"action": "stop"})).await;
9338        assert!(
9339            !state.recorder.is_recording(),
9340            "stop ends the second trace's recording"
9341        );
9342    }
9343
9344    #[tokio::test]
9345    async fn import_refuses_to_discard_an_active_recording() {
9346        let state = state_with(PrivacyConfig::default());
9347        state.recorder.start("live".to_string()).unwrap();
9348        let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
9349        let session = RecordedSession::new(
9350            "other".to_string(),
9351            chrono::Utc::now(),
9352            Vec::new(),
9353            Vec::new(),
9354        );
9355        let r = call(
9356            &h,
9357            "recording",
9358            json!({"action": "import", "session_json": serde_json::to_string(&session).unwrap()}),
9359        )
9360        .await;
9361        assert_eq!(r.is_error, Some(true), "{}", result_text(&r));
9362        assert_eq!(
9363            state.recorder.export().unwrap().id,
9364            "live",
9365            "active recording kept"
9366        );
9367    }
9368
9369    // ── introspect.contract_record / contract_check (audit #30, A2) ───────────
9370
9371    #[tokio::test]
9372    async fn contract_record_never_invokes_a_blocklisted_command() {
9373        let bridge = RecordingBridge::default();
9374        let state = state_with(blocking(&["delete_account"]));
9375        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9376
9377        let r = call(
9378            &h,
9379            "introspect",
9380            json!({"action": "contract_record", "command": "delete_account", "args": {"confirm": true}}),
9381        )
9382        .await;
9383
9384        assert!(
9385            !bridge.invoked("delete_account"),
9386            "SIDE-EFFECT LEAK: contract_record invoked a blocklisted command (audit #30)"
9387        );
9388        assert_eq!(r.is_error, Some(true));
9389        assert!(
9390            result_text(&r).contains("blocked by privacy configuration"),
9391            "got: {}",
9392            result_text(&r)
9393        );
9394    }
9395
9396    #[tokio::test]
9397    async fn contract_record_does_invoke_an_allowed_command() {
9398        let state = state_with(PrivacyConfig::default());
9399        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9400        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9401
9402        let _ = call(
9403            &h,
9404            "introspect",
9405            json!({"action": "contract_record", "command": "get_settings"}),
9406        )
9407        .await;
9408
9409        assert!(
9410            bridge.invoked("get_settings"),
9411            "positive control failed: contract_record did not invoke an allowed command"
9412        );
9413    }
9414
9415    /// R4-PANIC1: the stored sample was cut with `&s[..4096]`, which panics when byte 4096
9416    /// falls inside a multi-byte character — any non-ASCII response over 4 KiB.
9417    #[tokio::test]
9418    async fn contract_record_samples_a_large_non_ascii_response_without_panicking() {
9419        let state = eval_state_with_timeout(2_000);
9420        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9421        // The result text is `"ééé…"`: the opening quote puts every `é` on an odd byte
9422        // offset, so byte 4096 is the second byte of a character.
9423        let payload = "é".repeat(3000);
9424        bridge.answer_evals_with(
9425            &json!({"__victauri_ok": payload, "__victauri_type": "string"}).to_string(),
9426        );
9427        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9428        let r = call(
9429            &h,
9430            "introspect",
9431            json!({"action": "contract_record", "command": "get_notes"}),
9432        )
9433        .await;
9434        let text = result_text(&r);
9435        assert_ne!(r.is_error, Some(true), "{text}");
9436        let baseline = state
9437            .contract_store
9438            .all()
9439            .into_iter()
9440            .find(|b| b.command == "get_notes")
9441            .expect("baseline recorded");
9442        assert!(
9443            baseline.sample.ends_with("...(truncated)"),
9444            "{}",
9445            baseline.sample
9446        );
9447        assert!(baseline.sample.len() <= 4096 + "...(truncated)".len());
9448    }
9449
9450    // ── pending-eval concurrency ceiling (audit: TOCTOU race) ────────────────
9451    #[tokio::test]
9452    async fn reserve_pending_is_a_hard_ceiling_under_concurrency() {
9453        // A check-then-insert (lock, read len(), unlock, …, lock, insert) races: many
9454        // concurrent callers all pass a STALE len() check before any inserts, blowing past
9455        // MAX_PENDING_EVALS. `reserve_pending` checks AND inserts under one lock, so the cap
9456        // is a true ceiling. Pre-fill to MAX-5, fire 50 concurrent reservations: EXACTLY 5
9457        // may succeed and the map must NEVER exceed the cap.
9458        let state = state_with(PrivacyConfig::default());
9459        {
9460            let mut p = state.pending_evals.lock().await;
9461            for i in 0..(MAX_PENDING_EVALS - 5) {
9462                let (tx, _rx) = tokio::sync::oneshot::channel();
9463                p.insert(format!("pre-{i}"), tx);
9464            }
9465        }
9466        let h = Arc::new(VictauriMcpHandler::new(
9467            state.clone(),
9468            Arc::new(RecordingBridge::default()),
9469        ));
9470        let mut tasks = Vec::new();
9471        for i in 0..50 {
9472            let h = h.clone();
9473            tasks.push(tokio::spawn(async move {
9474                let (tx, _rx) = tokio::sync::oneshot::channel();
9475                // keep rx and the slot alive until the reservation has been decided
9476                let slot = h.reserve_pending(&format!("c-{i}"), tx).await.ok();
9477                (slot, _rx)
9478            }));
9479        }
9480        let mut granted = 0;
9481        let mut keep = Vec::new();
9482        for t in tasks {
9483            let (slot, rx) = t.await.unwrap();
9484            if slot.is_some() {
9485                granted += 1;
9486            }
9487            keep.push((slot, rx)); // hold slots so reserved entries are not released
9488        }
9489        let len = state.pending_evals.lock().await.len();
9490        assert!(
9491            len <= MAX_PENDING_EVALS,
9492            "ceiling breached: {len} > {MAX_PENDING_EVALS}"
9493        );
9494        assert_eq!(
9495            granted, 5,
9496            "exactly the 5 free slots should have been reserved, got {granted}"
9497        );
9498        drop(keep);
9499    }
9500
9501    #[tokio::test]
9502    async fn contract_check_never_reinvokes_a_now_blocklisted_command() {
9503        // A baseline recorded before the command was blocked must not be re-invoked
9504        // once the operator adds it to the blocklist (audit #30).
9505        let bridge = RecordingBridge::default();
9506        let state = state_with(blocking(&["delete_account"]));
9507        state
9508            .contract_store
9509            .record(crate::introspection::ContractBaseline {
9510                command: "delete_account".to_string(),
9511                args: json!({}),
9512                shape: crate::introspection::JsonShape::from_value(&json!(true)),
9513                sample: "true".to_string(),
9514                recorded_at: chrono_now(),
9515            });
9516        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9517
9518        let _ = call(&h, "introspect", json!({"action": "contract_check"})).await;
9519
9520        assert!(
9521            !bridge.invoked("delete_account"),
9522            "SIDE-EFFECT LEAK: contract_check re-invoked a now-blocklisted command (audit #30)"
9523        );
9524    }
9525
9526    // ── MCP resources honour the privacy gate (audit B1) ──────────────────────
9527
9528    #[test]
9529    fn resource_reads_are_gated_by_their_mirrored_capability() {
9530        // Resources bypass the tool dispatcher, so the read path must apply the same
9531        // gate. Disabling the capability a resource mirrors must block the resource.
9532        let cfg = PrivacyConfig {
9533            disabled_tools: HashSet::from([
9534                "logs.ipc".to_string(),
9535                "window.list".to_string(),
9536                "get_plugin_info".to_string(),
9537            ]),
9538            ..Default::default()
9539        };
9540        for uri in [
9541            RESOURCE_URI_IPC_LOG,
9542            RESOURCE_URI_WINDOWS,
9543            RESOURCE_URI_STATE,
9544        ] {
9545            let (_, cap) =
9546                resource_required_capability(uri).expect("resource maps to a capability");
9547            assert!(
9548                !cfg.is_tool_enabled(cap),
9549                "disabling capability {cap} must gate resource {uri} (audit B1)"
9550            );
9551            assert!(
9552                !resource_allowed(&cfg, uri),
9553                "disabling capability {cap} must gate resource {uri} (audit B1)"
9554            );
9555        }
9556        // Sanity: with nothing disabled, all three resources read.
9557        let full = PrivacyConfig::default();
9558        for uri in [
9559            RESOURCE_URI_IPC_LOG,
9560            RESOURCE_URI_WINDOWS,
9561            RESOURCE_URI_STATE,
9562        ] {
9563            assert!(full.is_tool_enabled(resource_required_capability(uri).unwrap().1));
9564            assert!(resource_allowed(&full, uri));
9565        }
9566    }
9567
9568    /// One JSON-RPC request to `/mcp` (legacy protocol, so the legacy subscribe method is
9569    /// routed); returns the response body text.
9570    async fn mcp_body(privacy: PrivacyConfig, method: &str, params: serde_json::Value) -> String {
9571        use http_body_util::BodyExt;
9572        use tower::ServiceExt;
9573        let app = build_app(state_with(privacy), Arc::new(RecordingBridge::default()));
9574        let body = json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params});
9575        let req = axum::http::Request::post("/mcp")
9576            .header("host", "127.0.0.1:7373")
9577            .header("content-type", "application/json")
9578            .header("accept", "application/json, text/event-stream")
9579            .header("mcp-protocol-version", "2025-06-18")
9580            .body(axum::body::Body::from(body.to_string()))
9581            .unwrap();
9582        let resp = tokio::time::timeout(std::time::Duration::from_secs(20), app.oneshot(req))
9583            .await
9584            .expect("an MCP request must be answered")
9585            .unwrap();
9586        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
9587        String::from_utf8_lossy(&bytes).into_owned()
9588    }
9589
9590    /// R4-NET3: a resource mirrors a tool action, and disabling the TOOL by its bare name
9591    /// (`disable_tools(["logs"])`) blocks every one of its actions — so it must block the
9592    /// resource too. The gate checked only the capability (`logs.ipc`), so the bare-name
9593    /// disable was ignored for resources (read and the legacy subscribe).
9594    #[tokio::test]
9595    async fn a_bare_tool_disable_also_blocks_its_resources() {
9596        for (disabled, uri) in [
9597            ("logs", RESOURCE_URI_IPC_LOG),
9598            ("window", RESOURCE_URI_WINDOWS),
9599            ("get_plugin_info", RESOURCE_URI_STATE),
9600        ] {
9601            let cfg = || PrivacyConfig {
9602                disabled_tools: HashSet::from([disabled.to_string()]),
9603                ..Default::default()
9604            };
9605            for method in ["resources/read", "resources/subscribe"] {
9606                let body = mcp_body(cfg(), method, json!({"uri": uri})).await;
9607                assert!(
9608                    body.contains("not permitted by the current privacy configuration"),
9609                    "disable_tools([{disabled:?}]) must block {method} {uri}: {body}"
9610                );
9611            }
9612        }
9613        // Positive control: nothing disabled, the resource reads.
9614        let body = mcp_body(
9615            PrivacyConfig::default(),
9616            "resources/read",
9617            json!({"uri": RESOURCE_URI_WINDOWS}),
9618        )
9619        .await;
9620        assert!(
9621            body.contains("\"contents\"") && !body.contains("not permitted"),
9622            "{body}"
9623        );
9624    }
9625
9626    // ── empty/whitespace auth token collapses to NO auth (audit B2) ───────────
9627
9628    #[tokio::test]
9629    async fn empty_auth_token_collapses_to_no_auth() {
9630        use http_body_util::BodyExt;
9631        use tower::ServiceExt;
9632
9633        for token in [Some(String::new()), Some("   ".to_string())] {
9634            let app = crate::mcp::server::build_app_full(
9635                state_with(PrivacyConfig::default()),
9636                Arc::new(RecordingBridge::default()),
9637                token.clone(),
9638                None,
9639            );
9640            let req = axum::extract::Request::builder()
9641                .uri("/info")
9642                .header("host", "127.0.0.1")
9643                .body(axum::body::Body::empty())
9644                .unwrap();
9645            let resp = app.oneshot(req).await.unwrap();
9646            assert_eq!(
9647                resp.status(),
9648                200,
9649                "/info must be reachable with empty token {token:?} (no auth layer)"
9650            );
9651            let bytes = resp.into_body().collect().await.unwrap().to_bytes();
9652            let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9653            assert_eq!(
9654                body["auth_required"],
9655                json!(false),
9656                "empty/whitespace token must report auth_required:false, not looks-protected-isnt (audit B2); token={token:?}"
9657            );
9658        }
9659    }
9660
9661    // ── app_info env allowlist drops secrets (audit #5/B3) ────────────────────
9662
9663    #[test]
9664    fn is_safe_env_key_drops_secrets_keeps_safe() {
9665        for secret in [
9666            "VICTAURI_AUTH_TOKEN",
9667            "TAURI_SIGNING_PRIVATE_KEY",
9668            "TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
9669            "CARGO_REGISTRY_TOKEN",
9670            "AWS_SECRET_ACCESS_KEY",
9671            "DATABASE_DSN",
9672            "GH_PAT",
9673        ] {
9674            assert!(
9675                !is_safe_env_key(secret),
9676                "{secret} is secret-shaped and must NOT be surfaced by app_info (audit #5)"
9677            );
9678        }
9679        for safe in [
9680            "HOME",
9681            "LANG",
9682            "TERM",
9683            "XDG_RUNTIME_DIR",
9684            "TAURI_ENV_PLATFORM",
9685        ] {
9686            assert!(
9687                is_safe_env_key(safe),
9688                "{safe} should be surfaced by app_info"
9689            );
9690        }
9691    }
9692}
9693
9694/// `screenshot` must refuse to "capture" a non-visible window.
9695///
9696/// Live-4DA dogfood (2026-06-16): requesting a hidden window (`label:"briefing"`)
9697/// returned a PNG that was actually the MAIN window's pixels — the OS capture path has
9698/// no live surface for an unmapped window, so it silently yields stale/foreign content.
9699/// The tool now checks visibility first and fails with an actionable message instead.
9700#[cfg(test)]
9701mod screenshot_visibility_tests {
9702    use super::*;
9703    use crate::bridge::WebviewBridge;
9704    use crate::privacy::PrivacyConfig;
9705    use std::collections::HashMap;
9706    use std::sync::Mutex as StdMutex;
9707    use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
9708
9709    fn window(label: &str, visible: bool) -> WindowState {
9710        WindowState::new(label.to_string())
9711            .with_title(label.to_string())
9712            .with_url("http://localhost/".to_string())
9713            .with_visible(visible)
9714            .with_focused(false)
9715            .with_maximized(false)
9716            .with_minimized(false)
9717            .with_fullscreen(false)
9718            .with_position(0, 0)
9719            .with_size(800, 600)
9720    }
9721
9722    /// A bridge with a configurable window set that RECORDS the label `get_native_handle`
9723    /// is asked for, then errs. Recording the label lets a test assert WHICH window the
9724    /// screenshot tool resolved to (the audit-P2 case: omitted label must resolve to a
9725    /// VISIBLE window, never hidden "main"); the error lets a test prove the visibility gate
9726    /// fired *before* the OS-handle path was reached.
9727    struct ConfigBridge {
9728        windows: Vec<WindowState>,
9729        handle_label: Arc<StdMutex<Option<Option<String>>>>,
9730    }
9731
9732    impl ConfigBridge {
9733        fn new(windows: Vec<WindowState>) -> Self {
9734            Self {
9735                windows,
9736                handle_label: Arc::new(StdMutex::new(None)),
9737            }
9738        }
9739        /// The label `get_native_handle` was called with, if it was reached.
9740        /// `Some(Some(l))` = called with label `l`; `Some(None)` = called with the default;
9741        /// `None` = never reached (gate short-circuited).
9742        fn requested_handle(&self) -> Option<Option<String>> {
9743            self.handle_label
9744                .lock()
9745                .unwrap_or_else(std::sync::PoisonError::into_inner)
9746                .clone()
9747        }
9748    }
9749
9750    impl WebviewBridge for ConfigBridge {
9751        fn eval_webview(&self, _l: Option<&str>, _s: &str) -> Result<(), String> {
9752            Err("no eval".to_string())
9753        }
9754        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9755            match label {
9756                Some(l) => self
9757                    .windows
9758                    .iter()
9759                    .filter(|w| w.label == l)
9760                    .cloned()
9761                    .collect(),
9762                None => self.windows.clone(),
9763            }
9764        }
9765        fn list_window_labels(&self) -> Vec<String> {
9766            self.windows.iter().map(|w| w.label.clone()).collect()
9767        }
9768        fn get_native_handle(&self, l: Option<&str>) -> Result<isize, String> {
9769            *self
9770                .handle_label
9771                .lock()
9772                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(l.map(str::to_string));
9773            Err("native handle path reached".to_string())
9774        }
9775        fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
9776            Ok(String::new())
9777        }
9778        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
9779            Ok(())
9780        }
9781        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
9782            Ok(())
9783        }
9784        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
9785            Ok(())
9786        }
9787    }
9788
9789    fn handler_with(bridge: Arc<ConfigBridge>) -> VictauriMcpHandler {
9790        let state = Arc::new(VictauriState {
9791            event_log: EventLog::new(100),
9792            registry: CommandRegistry::new(),
9793            port: std::sync::atomic::AtomicU16::new(0),
9794            pending_evals: Arc::new(Mutex::new(HashMap::new())),
9795            recorder: EventRecorder::new(100),
9796            privacy: PrivacyConfig::default(),
9797            eval_timeout: std::time::Duration::from_millis(100),
9798            shutdown_tx: tokio::sync::watch::channel(false).0,
9799            started_at: std::time::Instant::now(),
9800            tool_invocations: std::sync::atomic::AtomicU64::new(0),
9801            allow_file_navigation: false,
9802            command_timings: crate::introspection::CommandTimings::new(),
9803            fault_registry: crate::introspection::FaultRegistry::new(),
9804            contract_store: crate::introspection::ContractStore::new(),
9805            startup_timeline: crate::introspection::StartupTimeline::new(),
9806            event_bus: crate::introspection::EventBusMonitor::default(),
9807            task_tracker: crate::introspection::TaskTracker::new(),
9808            bridge_ready: std::sync::atomic::AtomicBool::new(true),
9809            bridge_notify: tokio::sync::Notify::new(),
9810            db_search_paths: Vec::new(),
9811            screencast: Arc::new(crate::screencast::Screencast::default()),
9812            probes: crate::introspection::AppStateProbes::default(),
9813            drain_watermarks: crate::introspection::DrainWatermarks::default(),
9814            page_loads: crate::introspection::PageLoads::default(),
9815        });
9816        VictauriMcpHandler::new(state, bridge)
9817    }
9818
9819    fn error_text(r: &CallToolResult) -> String {
9820        r.content
9821            .iter()
9822            .filter_map(|c| match c {
9823                ContentBlock::Text(t) => Some(t.text.clone()),
9824                _ => None,
9825            })
9826            .collect::<Vec<_>>()
9827            .join("\n")
9828    }
9829
9830    #[tokio::test]
9831    async fn hidden_window_screenshot_errors_clearly() {
9832        let bridge = Arc::new(ConfigBridge::new(vec![
9833            window("main", true),
9834            window("briefing", false),
9835        ]));
9836        let h = handler_with(bridge.clone());
9837        let r = h
9838            .screenshot(Parameters(ScreenshotParams {
9839                window_label: Some("briefing".to_string()),
9840            }))
9841            .await;
9842        assert_eq!(r.is_error, Some(true), "hidden window must error");
9843        let text = error_text(&r);
9844        assert!(
9845            text.contains("not visible"),
9846            "error must explain the window is not visible, got: {text}"
9847        );
9848        assert!(
9849            bridge.requested_handle().is_none(),
9850            "must short-circuit BEFORE the OS-handle/capture path"
9851        );
9852    }
9853
9854    #[tokio::test]
9855    async fn visible_window_screenshot_proceeds_to_capture() {
9856        let bridge = Arc::new(ConfigBridge::new(vec![
9857            window("main", true),
9858            window("briefing", false),
9859        ]));
9860        let h = handler_with(bridge.clone());
9861        let r = h
9862            .screenshot(Parameters(ScreenshotParams {
9863                window_label: Some("main".to_string()),
9864            }))
9865            .await;
9866        // The gate must let a visible window THROUGH to the OS-handle path (which this mock
9867        // fails) — proving the gate only blocks hidden windows.
9868        let text = error_text(&r);
9869        assert!(
9870            text.contains("native handle path reached")
9871                || text.contains("cannot get window handle"),
9872            "a visible window must reach the capture path, got: {text}"
9873        );
9874        assert_eq!(
9875            bridge.requested_handle(),
9876            Some(Some("main".to_string())),
9877            "must capture the explicitly requested visible window"
9878        );
9879    }
9880
9881    // GPT audit (P2): `screenshot {}` with NO label previously resolved through
9882    // find_window(None), which prefers "main" UNCONDITIONALLY — so an app that hides main but
9883    // keeps a secondary window visible captured hidden main (the wrong-pixels class the PR
9884    // exists to prevent). The tool must now resolve its own VISIBLE target.
9885    #[tokio::test]
9886    async fn omitted_label_skips_hidden_main_for_visible_secondary() {
9887        let bridge = Arc::new(ConfigBridge::new(vec![
9888            window("main", false),     // main hidden
9889            window("secondary", true), // a different window is visible
9890        ]));
9891        let h = handler_with(bridge.clone());
9892        let r = h
9893            .screenshot(Parameters(ScreenshotParams { window_label: None }))
9894            .await;
9895        let text = error_text(&r);
9896        assert!(
9897            !text.contains("not visible") && !text.contains("no visible window"),
9898            "a visible secondary window exists — must NOT error, got: {text}"
9899        );
9900        assert_eq!(
9901            bridge.requested_handle(),
9902            Some(Some("secondary".to_string())),
9903            "omitted label must resolve to the VISIBLE secondary, never hidden main"
9904        );
9905    }
9906
9907    // Omitted label with a visible main present must still prefer "main".
9908    #[tokio::test]
9909    async fn omitted_label_prefers_visible_main() {
9910        let bridge = Arc::new(ConfigBridge::new(vec![
9911            window("main", true),
9912            window("secondary", true),
9913        ]));
9914        let h = handler_with(bridge.clone());
9915        let _ = h
9916            .screenshot(Parameters(ScreenshotParams { window_label: None }))
9917            .await;
9918        assert_eq!(
9919            bridge.requested_handle(),
9920            Some(Some("main".to_string())),
9921            "with a visible main present, omitted label must resolve to main"
9922        );
9923    }
9924
9925    // Every window hidden + omitted label: error clearly, never capture a hidden window.
9926    #[tokio::test]
9927    async fn all_hidden_omitted_label_errors() {
9928        let bridge = Arc::new(ConfigBridge::new(vec![
9929            window("main", false),
9930            window("briefing", false),
9931        ]));
9932        let h = handler_with(bridge.clone());
9933        let r = h
9934            .screenshot(Parameters(ScreenshotParams { window_label: None }))
9935            .await;
9936        assert_eq!(r.is_error, Some(true), "all-hidden must error");
9937        assert!(
9938            error_text(&r).contains("no visible window"),
9939            "error must say there is no visible window, got: {}",
9940            error_text(&r)
9941        );
9942        assert!(
9943            bridge.requested_handle().is_none(),
9944            "must NOT reach the OS-handle path when every window is hidden"
9945        );
9946    }
9947
9948    // An unknown explicit label is passed THROUGH to get_native_handle (which produces the
9949    // canonical "window not found"), not rejected as "not visible".
9950    #[tokio::test]
9951    async fn unknown_label_falls_through_to_handle_resolution() {
9952        let bridge = Arc::new(ConfigBridge::new(vec![window("main", true)]));
9953        let h = handler_with(bridge.clone());
9954        let r = h
9955            .screenshot(Parameters(ScreenshotParams {
9956                window_label: Some("ghost".to_string()),
9957            }))
9958            .await;
9959        assert!(
9960            !error_text(&r).contains("not visible"),
9961            "unknown label must not be reported as 'not visible'"
9962        );
9963        assert_eq!(
9964            bridge.requested_handle(),
9965            Some(Some("ghost".to_string())),
9966            "unknown label must be forwarded verbatim to get_native_handle"
9967        );
9968    }
9969}