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_log_tools;
9mod backend_params;
10mod bounded;
11mod compound_params;
12#[cfg(test)]
13mod drain_tests;
14mod hardening;
15mod helpers;
16mod introspection_params;
17mod other_params;
18pub(crate) mod page_json;
19mod rest;
20#[cfg(test)]
21mod robustness_tests;
22mod server;
23#[cfg(test)]
24mod tool_result_tests;
25mod verification_params;
26mod webview_params;
27mod window_params;
28
29use std::collections::{HashMap, HashSet};
30use std::sync::Arc;
31use std::sync::atomic::{AtomicBool, Ordering};
32
33use rmcp::handler::server::tool::ToolCallContext;
34use rmcp::handler::server::wrapper::Parameters;
35use rmcp::model::{
36    CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock,
37    InitializeResult, ListResourcesResult, ListToolsResult, PaginatedRequestParams,
38    ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, Resource,
39    ResourceContents, ServerCapabilities, SubscribeRequestParams, Tool, UnsubscribeRequestParams,
40};
41use rmcp::service::RequestContext;
42use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_router};
43use tokio::sync::Mutex;
44
45use crate::VictauriState;
46use crate::bridge::WebviewBridge;
47
48use helpers::{
49    RecoveryHint, build_ghost_report, ghost_ipc_outcomes_js, ghost_ipc_projection_js,
50    ipc_catalog_projection_js, ipc_timing_projection_js, ipc_timing_stats, js_string, json_result,
51    json_truthy, merge_command_catalog, missing_param, sanitize_css_color, sanitize_injected_css,
52    tool_disabled, tool_error, tool_error_with_hint, truncate_at_char_boundary, validate_url,
53};
54
55// MCP tool *parameter* types are an internal protocol surface: they are deserialized
56// from MCP/JSON, used only inside this crate's (private) tool methods, and change every
57// release as actions/fields are added. They are deliberately NOT part of the public API
58// (`pub(crate)`, not `pub use`), so adding a tool action or field is not a breaking change
59// and `cargo semver-checks` stays meaningful. Only `server::*` (build_app*,
60// VictauriMcpHandler) is the public MCP surface consumers actually use.
61pub(crate) use backend_params::*;
62pub(crate) use compound_params::*;
63/// Page-side probes run before trusted (OS-level) input. Internal: public only so the
64/// crate's own integration tests can run them in a JS engine. Not part of the supported API.
65#[doc(hidden)]
66pub use helpers::{trusted_click_probe_js, trusted_focus_probe_js};
67
68/// The IPC-log JS the tools send to the page. Internal plumbing, `pub` only so the jsdom suite
69/// (`tests/bridge_r5_tests.rs`) can run it against the real bridge — from a test binary of its
70/// own, since a seconds-long `node` child spawned from the library tests can inherit (and hold
71/// open) another test's server socket on Windows.
72#[doc(hidden)]
73pub mod ipc_log_js {
74    pub use super::helpers::{
75        ghost_ipc_outcomes_js, ipc_catalog_projection_js, ipc_timing_projection_js,
76    };
77    pub use super::{ipc_integrity_js, slow_ipc_js, trimmed_log_js};
78}
79pub(crate) use introspection_params::*;
80pub(crate) use other_params::{
81    AppStateParams, DiagnosticsParams, FindElementsParams, ResolveCommandParams,
82    SemanticAssertParams, WaitCondition, WaitForParams,
83};
84pub use server::*;
85pub(crate) use verification_params::*;
86pub(crate) use webview_params::*;
87pub(crate) use window_params::*;
88
89// ── MCP Handler ──────────────────────────────────────────────────────────────
90
91/// Maximum number of in-flight JavaScript eval requests. Prevents unbounded
92/// growth of the `pending_evals` map if callbacks are never resolved.
93pub(crate) const MAX_PENDING_EVALS: usize = 100;
94
95fn chrono_now() -> String {
96    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
97}
98
99/// Maximum length of JavaScript code accepted by the `eval_js` tool (1 MB).
100const MAX_EVAL_CODE_LEN: usize = 1_000_000;
101
102/// Maximum length of a JavaScript eval return value (5 MB).
103/// Results exceeding this are truncated to prevent memory exhaustion.
104const MAX_EVAL_RESULT_LEN: usize = 5_000_000;
105
106/// Default number of entries returned by IPC/network log tools when no explicit
107/// `limit` is given. Prevents busy apps (large logs) from exceeding the eval cap.
108const DEFAULT_LOG_LIMIT: usize = 100;
109
110/// Per-field byte cap applied to each IPC/network log entry before serialization.
111/// Large request/response bodies are truncated with a marker so the aggregate
112/// log stays well under [`MAX_EVAL_RESULT_LEN`] even on heavy-traffic apps.
113const MAX_LOG_FIELD_BYTES: usize = 4096;
114
115/// Hard cap on entries returned by `list_app_dir` (recursive). Without it a
116/// directory with millions of files (or a wide tree at max depth) would build an
117/// unbounded result Vec and blow the eval/output cap (audit B7). When hit, the
118/// listing stops and the response is marked `truncated: true`.
119const MAX_DIR_ENTRIES: usize = 10_000;
120/// Cap on directory entries `list_app_dir` EXAMINES (returned or not): with a `pattern` that
121/// matches nothing, [`MAX_DIR_ENTRIES`] never trips and the walk used to cover the whole tree.
122const MAX_DIR_VISITED: usize = 100_000;
123/// Wall-clock budget for one `list_app_dir` walk (slow or network filesystems).
124const MAX_DIR_WALK_TIME: std::time::Duration = std::time::Duration::from_secs(5);
125
126/// One bounded `list_app_dir` walk: stops (and reports `truncated`) at [`MAX_DIR_ENTRIES`]
127/// returned, [`MAX_DIR_VISITED`] examined, or [`MAX_DIR_WALK_TIME`].
128struct DirWalk {
129    /// The listing root, canonicalized ONCE (it used to be re-canonicalized per entry).
130    canon_base: std::path::PathBuf,
131    pattern: Option<String>,
132    max_depth: u32,
133    deadline: std::time::Instant,
134    max_visited: usize,
135    visited: usize,
136    truncated: bool,
137    entries: Vec<serde_json::Value>,
138}
139
140impl DirWalk {
141    fn out_of_budget(&mut self) -> bool {
142        if self.entries.len() >= MAX_DIR_ENTRIES
143            || self.visited >= self.max_visited
144            || std::time::Instant::now() >= self.deadline
145        {
146            self.truncated = true;
147        }
148        self.truncated
149    }
150
151    fn visit(&mut self, dir: &std::path::Path, base: &std::path::Path, depth: u32) {
152        let Ok(read_dir) = std::fs::read_dir(dir) else {
153            return;
154        };
155        for entry in read_dir.flatten() {
156            if self.out_of_budget() {
157                return;
158            }
159            self.visited += 1;
160            let path = entry.path();
161            if path.is_symlink() {
162                continue;
163            }
164            // `is_symlink` does not cover every redirecting filesystem object
165            // (notably Windows directory junctions/reparse points). Canonical
166            // containment is the actual boundary before metadata or recursion.
167            if !std::fs::canonicalize(&path).is_ok_and(|c| c.starts_with(&self.canon_base)) {
168                continue;
169            }
170            let name = entry.file_name().to_string_lossy().into_owned();
171            let is_dir = path.is_dir();
172            if let Some(pat) = self.pattern.as_deref()
173                && !is_dir
174                && !VictauriMcpHandler::matches_glob(&name, pat)
175            {
176                continue;
177            }
178            let relative = path
179                .strip_prefix(base)
180                .unwrap_or(&path)
181                .to_string_lossy()
182                .into_owned();
183            let meta = std::fs::metadata(&path).ok();
184
185            self.entries.push(serde_json::json!({
186                "name": name,
187                "path": relative,
188                "is_dir": is_dir,
189                "size": meta.as_ref().map(std::fs::Metadata::len),
190                "modified": meta.as_ref()
191                    .and_then(|m| m.modified().ok())
192                    .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
193                        .unwrap_or_default().as_secs()),
194            }));
195
196            if is_dir && depth < self.max_depth {
197                self.visit(&path, base, depth + 1);
198            }
199        }
200    }
201}
202
203/// `db_health` performs integrity checks and table counts against app-owned
204/// databases. Each size-dependent phase is bounded separately (see
205/// `database::db_health_report`) so a large or adversarial DB cannot hold a blocking
206/// worker indefinitely — and a slow phase degrades to a reported partial result instead
207/// of discarding the cheap ones.
208#[cfg(feature = "sqlite")]
209const DB_HEALTH_COUNT_BUDGET: std::time::Duration = std::time::Duration::from_secs(5);
210#[cfg(feature = "sqlite")]
211const DB_HEALTH_CHECK_BUDGET: std::time::Duration = std::time::Duration::from_secs(5);
212
213/// A timeout for an error message: whole seconds, or milliseconds when under a second (so a
214/// short per-call timeout never reads as "timed out after 0s").
215fn format_timeout(timeout: std::time::Duration) -> String {
216    if timeout < std::time::Duration::from_secs(1) {
217        format!("{}ms", timeout.as_millis())
218    } else {
219        format!("{}s", timeout.as_secs())
220    }
221}
222
223/// Error text for a window query the UI thread did not answer: a wedged/busy UI must never
224/// read as "no windows" or "window not found".
225fn ui_busy(error: &str) -> String {
226    format!("UI thread busy (dispatch timed out) - window state is unavailable, not empty: {error}")
227}
228
229/// The error result for a tool handler that panicked (see `bounded::CatchUnwind`).
230fn tool_panicked(tool: &str, panic: &str) -> CallToolResult {
231    tool_error(format!(
232        "internal error: the '{tool}' handler panicked ({panic}); the server is still running"
233    ))
234}
235
236/// Upper bound for `invoke_command`'s per-call `timeout_ms` (matches the eval-timeout ceiling).
237const MAX_INVOKE_TIMEOUT_MS: u64 = 300_000;
238
239/// How an eval call failed — typed, so a caller never infers from error text whether the code
240/// ran (e.g. whether the call is a command timing).
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242enum EvalFailureKind {
243    /// The code never ran: the call was refused or never delivered (saturated pending map, dead
244    /// bridge, failed injection, app exiting), or it did not parse.
245    NotSent,
246    /// Cut off in flight (timeout, app exit, window closed, page reload): it may or may not
247    /// have run.
248    Aborted,
249    /// The code ran in the page and threw, or ran but its result could not be returned.
250    Page,
251}
252
253/// A failed eval: what happened to the code, and the message for the agent.
254#[derive(Debug, Clone, PartialEq, Eq)]
255struct EvalFailure {
256    kind: EvalFailureKind,
257    message: String,
258}
259
260impl EvalFailure {
261    fn new(kind: EvalFailureKind, message: impl Into<String>) -> Self {
262        Self {
263            kind,
264            message: message.into(),
265        }
266    }
267
268    /// The code did not parse (reported by the bridge's post-wrapper check), so it never ran.
269    fn is_parse_error(&self) -> bool {
270        self.kind == EvalFailureKind::NotSent && self.message.starts_with(PARSE_ERROR_PREFIX)
271    }
272}
273
274/// How [`unwrap_eval_envelope`] reports code that did not parse.
275const PARSE_ERROR_PREFIX: &str = "JavaScript parse error:";
276
277/// How long `app_state` waits for an app-registered probe closure.
278///
279/// This used to be 1 s under `cfg(test)` for EVERY probe call, so a test of a panicking or a
280/// normal probe raced a deadline shorter than the scheduling tail of a loaded machine (a fresh
281/// blocking-pool thread plus the panic hook and unwind measured ~0.4 s at 2x CPU
282/// oversubscription, more in a full parallel suite) and flaked as "did not finish". Tests that
283/// exercise the deadline itself shorten [`VictauriMcpHandler::probe_timeout`] on their handler.
284const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
285
286/// App-registered probes allowed to run at once. A probe that hangs keeps its blocking thread
287/// past [`PROBE_TIMEOUT`]; the cap stops repeated calls to it from leaking a thread each.
288pub(crate) const MAX_CONCURRENT_PROBES: usize = 4;
289
290/// `read_app_file` reads allowed to run at once (a read blocked on a FIFO or slow device keeps
291/// its thread past [`READ_APP_FILE_TIMEOUT`]).
292pub(crate) const MAX_CONCURRENT_FILE_READS: usize = 4;
293
294/// How long `read_app_file` waits for its (bounded, at most 10 MB) read. Tests that exercise
295/// the deadline shorten [`VictauriMcpHandler::file_read_timeout`] (see [`PROBE_TIMEOUT`]).
296const READ_APP_FILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
297
298/// Distinct command names `CommandTimings` tracks (its private `MAX_TIMED_COMMANDS`, mirrored
299/// here — a test pins the two together). Once that many are tracked, new names are dropped and
300/// `introspect command_timings` reports `saturated: true`.
301pub(crate) const COMMAND_TIMINGS_CAP: usize = 1024;
302
303/// Upper bound for an injected `fault` delay (matches the `wait_for` ceiling).
304const MAX_FAULT_DELAY_MS: u64 = 120_000;
305
306/// Pixels between (and around) the cells of an `animation scrub` filmstrip.
307const FILMSTRIP_GAP: u32 = 4;
308
309/// Total time `recording stop` spends on its final flush of every window.
310const FINAL_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
311/// How often a slow eval re-checks that its target window still exists (first check after
312/// one interval, so fast evals never pay for it).
313const EVAL_WINDOW_WATCH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
314
315/// The least time an eval may spend SENDING (liveness probe + injection), however short its
316/// timeout: room for the probe's full 2 s answer window plus the injection — a probe cut short
317/// cannot tell a dead page from a busy one (see `eval_outcome`, `probe_bridge`).
318const MIN_SEND_WINDOW: std::time::Duration = std::time::Duration::from_millis(2_500);
319
320/// The least time code that reached the page gets to answer (capped by its own timeout), even
321/// when sending it ran close to the deadline (see `eval_outcome`).
322const MIN_RESULT_WINDOW: std::time::Duration = std::time::Duration::from_secs(1);
323
324/// How long the liveness probe waits for the page to answer once it was dispatched (see
325/// `probe_bridge`).
326const PROBE_ANSWER_WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
327
328/// Extra eval time a page-side `wait_for` gets beyond its own `timeout_ms`, for sending the code
329/// (see `wait_for`): the probe's start wait (≤ 10 s) + its answer window (2 s) + the
330/// injection's own start wait (≤ 10 s), plus 2 s margin.
331const WAIT_FOR_SEND_SLACK_MS: u64 = 24_000;
332/// An abandoned `trace` auto-stops after this long (it captures a window every interval).
333const MAX_TRACE_DURATION: std::time::Duration = std::time::Duration::from_secs(30 * 60);
334/// Cap on the base64 bytes returned by one `trace frames` call.
335const MAX_TRACE_FRAMES_RESPONSE_BYTES: usize = 25 * 1024 * 1024;
336
337const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
338const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
339const RESOURCE_URI_STATE: &str = "victauri://state";
340
341/// SEP-2549 freshness hint for `tools/list` / `resources/list` results (5 minutes).
342/// Both lists are fixed for the process lifetime, so clients may cache them; a
343/// `notifications/tools/list_changed` (emitted by the CLI bridge on backend swap)
344/// still invalidates earlier. Conservative rather than "forever" so a client that
345/// only honors TTLs re-syncs within minutes of an app rebuild on the same port.
346const LIST_RESULT_TTL_MS: u64 = 300_000;
347
348/// Map an MCP resource URI to the tool call it mirrors: `(bare tool, capability)`.
349/// Resources are served outside the tool dispatcher, so `read_resource`/`subscribe` apply
350/// the same gate as a call of that tool action (audit B1) — including a disable of the
351/// whole tool by its bare name (R4-NET3). Returns `None` for an unknown URI (handled as
352/// not-found downstream).
353fn resource_required_capability(uri: &str) -> Option<(&'static str, &'static str)> {
354    match uri {
355        // Reading the IPC log via a resource == the `logs ipc` tool action.
356        RESOURCE_URI_IPC_LOG => Some(("logs", "logs.ipc")),
357        // Window states == the `window list` action.
358        RESOURCE_URI_WINDOWS => Some(("window", "window.list")),
359        // The state summary == reading plugin info.
360        RESOURCE_URI_STATE => Some(("get_plugin_info", "get_plugin_info")),
361        _ => None,
362    }
363}
364
365/// Whether the privacy configuration permits reading (or subscribing to) resource `uri`:
366/// exactly when it permits the tool call the resource mirrors. An unknown URI is not gated
367/// here (it is reported as not found).
368fn resource_allowed(privacy: &crate::privacy::PrivacyConfig, uri: &str) -> bool {
369    resource_required_capability(uri)
370        .is_none_or(|(tool, capability)| privacy.is_call_allowed(tool, capability))
371}
372
373const BRIDGE_VERSION: &str = env!("CARGO_PKG_VERSION");
374
375const SAFE_ENV_PREFIXES: &[&str] = &[
376    "HOME",
377    "USER",
378    "LANG",
379    "LC_",
380    "TERM",
381    "SHELL",
382    "DISPLAY",
383    "XDG_",
384    // Only Tauri's build-env namespace, NOT all of TAURI_ — the latter is an
385    // app-custom namespace that can hold secrets (audit #5).
386    "TAURI_ENV_",
387    "VICTAURI_",
388    "NODE_ENV",
389    "OS",
390    "HOSTNAME",
391    "PWD",
392    "SHLVL",
393    "LOGNAME",
394];
395
396/// Substrings that mark an env var as a secret. Even when a name matches a
397/// `SAFE_ENV_PREFIXES` entry it is dropped if it contains one of these — a prefix
398/// like `TAURI_`/`VICTAURI_` otherwise leaks `TAURI_SIGNING_PRIVATE_KEY`,
399/// `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, or `VICTAURI_AUTH_TOKEN` (audit #5).
400const SECRET_ENV_SUBSTRINGS: &[&str] = &[
401    "TOKEN",
402    "SECRET",
403    "PASS", // PASSWORD, PASSWD, PASSPHRASE
404    "PRIVATE",
405    "CREDENTIAL",
406    "APIKEY",
407    "AUTH",
408    "_KEY",
409    "DSN", // connection strings with embedded creds
410    "PAT", // personal access token
411    "JWT",
412    "BEARER",
413    "SESSION",
414    "COOKIE",
415    "SALT",
416    "CERT",
417    "SIGN", // signing keys/material
418    "LICENSE",
419];
420
421/// Whether an env var name is safe to surface via `app_info`: it must match a
422/// known-safe prefix AND not look like a secret (audit #5).
423fn is_safe_env_key(key: &str) -> bool {
424    let upper = key.to_uppercase();
425    SAFE_ENV_PREFIXES
426        .iter()
427        .any(|prefix| upper.starts_with(prefix))
428        && !SECRET_ENV_SUBSTRINGS.iter().any(|s| upper.contains(s))
429}
430
431/// MCP tool handler that dispatches tool calls to the webview bridge and state.
432#[derive(Clone)]
433pub struct VictauriMcpHandler {
434    state: Arc<VictauriState>,
435    bridge: Arc<dyn WebviewBridge>,
436    subscriptions: Arc<Mutex<HashSet<String>>>,
437    bridge_checked: Arc<AtomicBool>,
438    /// Window keys whose previous eval timed out. Retained only to annotate the
439    /// error on the *next* eval (the bridge is probed before every eval anyway).
440    timed_out_labels: Arc<Mutex<HashSet<String>>>,
441    /// Slots for running app probes ([`MAX_CONCURRENT_PROBES`]); held by the probe's thread.
442    probe_slots: Arc<tokio::sync::Semaphore>,
443    /// Slots for `read_app_file` reads ([`MAX_CONCURRENT_FILE_READS`]); held by the read's thread.
444    file_slots: Arc<tokio::sync::Semaphore>,
445    /// Deadline for one app probe ([`PROBE_TIMEOUT`]; tests of the deadline shorten it).
446    probe_timeout: std::time::Duration,
447    /// Deadline for one `read_app_file` read ([`READ_APP_FILE_TIMEOUT`]; likewise).
448    file_read_timeout: std::time::Duration,
449}
450
451#[tool_router]
452impl VictauriMcpHandler {
453    // ── Standalone Tools ────────────────────────────────────────────────────
454
455    #[tool(
456        description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.",
457        annotations(
458            read_only_hint = false,
459            destructive_hint = true,
460            idempotent_hint = false,
461            open_world_hint = false
462        )
463    )]
464    async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
465        if !self.state.privacy.is_tool_enabled("eval_js") {
466            return tool_disabled("eval_js");
467        }
468        if params.code.len() > MAX_EVAL_CODE_LEN {
469            return tool_error("code exceeds maximum length (1 MB)");
470        }
471        match self
472            .eval_with_return(&params.code, params.webview_label.as_deref())
473            .await
474        {
475            Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
476            Err(e) => tool_error(e),
477        }
478    }
479
480    #[tool(
481        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).",
482        annotations(
483            read_only_hint = true,
484            destructive_hint = false,
485            idempotent_hint = true,
486            open_world_hint = false
487        )
488    )]
489    async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
490        let format = params.format.unwrap_or(SnapshotFormat::Compact);
491        let format_str = match format {
492            SnapshotFormat::Compact => "compact",
493            SnapshotFormat::Json => "json",
494        };
495        let code = format!(
496            "return window.__VICTAURI__?.snapshot({})",
497            js_string(format_str)
498        );
499        self.eval_bridge(&code, params.webview_label.as_deref())
500            .await
501    }
502
503    #[tool(
504        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.",
505        annotations(
506            read_only_hint = true,
507            destructive_hint = false,
508            idempotent_hint = true,
509            open_world_hint = false
510        )
511    )]
512    async fn find_elements(
513        &self,
514        Parameters(params): Parameters<FindElementsParams>,
515    ) -> CallToolResult {
516        let mut parts: Vec<String> = Vec::new();
517        if let Some(t) = &params.text {
518            parts.push(format!("text: {}", js_string(t)));
519        }
520        if let Some(r) = &params.role {
521            parts.push(format!("role: {}", js_string(r)));
522        }
523        if let Some(tid) = &params.test_id {
524            parts.push(format!("test_id: {}", js_string(tid)));
525        }
526        if let Some(c) = params.css.as_ref().or(params.selector.as_ref()) {
527            parts.push(format!("css: {}", js_string(c)));
528        }
529        if let Some(n) = &params.name {
530            parts.push(format!("name: {}", js_string(n)));
531        }
532        if let Some(max) = params.max_results {
533            parts.push(format!("max_results: {max}"));
534        }
535        if let Some(t) = &params.tag {
536            parts.push(format!("tag: {}", js_string(t)));
537        }
538        if let Some(p) = &params.placeholder {
539            parts.push(format!("placeholder: {}", js_string(p)));
540        }
541        if let Some(a) = &params.alt {
542            parts.push(format!("alt: {}", js_string(a)));
543        }
544        if let Some(ta) = &params.title_attr {
545            parts.push(format!("title_attr: {}", js_string(ta)));
546        }
547        if let Some(l) = &params.label {
548            parts.push(format!("label: {}", js_string(l)));
549        }
550        if let Some(true) = params.exact {
551            parts.push("exact: true".to_string());
552        }
553        if let Some(e) = params.enabled {
554            parts.push(format!("enabled: {e}"));
555        }
556        let code = format!(
557            "return window.__VICTAURI__?.findElements({{ {} }})",
558            parts.join(", ")
559        );
560        match self
561            .eval_with_return(&code, params.webview_label.as_deref())
562            .await
563        {
564            Ok(result) => {
565                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
566                    && let Some(err) = parsed.get("error").and_then(|e| e.as_str())
567                {
568                    return tool_error(err);
569                }
570                CallToolResult::success(vec![ContentBlock::text(result)])
571            }
572            Err(e) => tool_error(e),
573        }
574    }
575
576    #[tool(
577        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 — or, with `with_logs: true`, `{result, backend_logs}`: the Rust log entries captured while it ran. Waits up to the eval timeout (30s) — pass `timeout_ms` (max 300000) for a legitimately slow command. Subject to privacy command filtering.",
578        annotations(
579            read_only_hint = false,
580            destructive_hint = true,
581            idempotent_hint = false,
582            open_world_hint = false
583        )
584    )]
585    async fn invoke_command(
586        &self,
587        Parameters(params): Parameters<InvokeCommandParams>,
588    ) -> CallToolResult {
589        if !self.state.privacy.is_invoke_allowed(&params.command) {
590            return tool_disabled("invoke_command");
591        }
592        if !self.state.privacy.is_command_allowed(&params.command) {
593            return tool_error(format!(
594                "command '{}' is blocked by privacy configuration",
595                params.command
596            ));
597        }
598
599        // ── Fault injection check ──
600        if let Some(fault) = self.state.fault_registry.check_and_trigger(&params.command) {
601            match fault {
602                crate::introspection::FaultType::Delay { delay_ms } => {
603                    tracing::info!(
604                        command = %params.command,
605                        delay_ms = delay_ms,
606                        "fault injection: delaying command"
607                    );
608                    tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
609                    // After delay, continue with normal execution below
610                }
611                crate::introspection::FaultType::Error { ref message } => {
612                    tracing::info!(
613                        command = %params.command,
614                        "fault injection: returning error"
615                    );
616                    return tool_error(format!(
617                        "[FAULT INJECTED] command '{}': {message}",
618                        params.command
619                    ));
620                }
621                crate::introspection::FaultType::Drop => {
622                    tracing::info!(
623                        command = %params.command,
624                        "fault injection: dropping response"
625                    );
626                    return CallToolResult::success(vec![ContentBlock::text("{}")]);
627                }
628                crate::introspection::FaultType::Corrupt => {
629                    tracing::info!(
630                        command = %params.command,
631                        "fault injection: corrupting response"
632                    );
633                    // Execute normally but mangle the response
634                    let args_json = params.args.unwrap_or(serde_json::json!({}));
635                    let args_str =
636                        serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
637                    let code = format!(
638                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
639                        js_string(&params.command)
640                    );
641                    if let Ok(result) = self
642                        .eval_with_return(&code, params.webview_label.as_deref())
643                        .await
644                    {
645                        let corrupted = format!(
646                            "{{\"__corrupted\":true,\"original_length\":{},\"fault\":\"corrupt\"}}",
647                            result.len()
648                        );
649                        return CallToolResult::success(vec![ContentBlock::text(corrupted)]);
650                    }
651                    return CallToolResult::success(vec![ContentBlock::text(
652                        "{\"__corrupted\":true,\"fault\":\"corrupt\",\"note\":\"original invocation also failed\"}",
653                    )]);
654                }
655            }
656        }
657
658        // ── Normal execution with timing ──
659        // `with_logs` returns backend log entries, so it needs `logs.backend` like reading them
660        // directly — checked before the command runs, so a refused read has no side effects.
661        let with_logs = params.with_logs.unwrap_or(false);
662        if with_logs && !self.state.privacy.is_call_allowed("logs", "logs.backend") {
663            return tool_disabled("logs.backend");
664        }
665        let log_cursor = with_logs.then(|| self.backend_log_cursor());
666        let fail = |message: String| match log_cursor {
667            Some(cursor) => self.backend_logs_error(&message, cursor),
668            None => tool_error(message),
669        };
670        let start = std::time::Instant::now();
671        let args_json = params.args.unwrap_or(serde_json::json!({}));
672        let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
673        let code = format!(
674            "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
675            js_string(&params.command)
676        );
677        let timeout = params.timeout_ms.map_or(self.state.eval_timeout, |ms| {
678            std::time::Duration::from_millis(ms.clamp(1, MAX_INVOKE_TIMEOUT_MS))
679        });
680        let result = self
681            .eval_outcome(&code, params.webview_label.as_deref(), timeout)
682            .await;
683        let elapsed = start.elapsed();
684        // Only calls that reached the command are timings: one never sent (saturated, dead
685        // bridge, failed injection) measures nothing, and one cut off (timeout, app exit, closed
686        // window, reload) measures how long we waited — both skewed p95.
687        let ran = result
688            .as_ref()
689            .map_or_else(|f| f.kind == EvalFailureKind::Page, |_| true);
690        if ran {
691            self.state.command_timings.record(&params.command, elapsed);
692        }
693        match result {
694            Ok(result) => {
695                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
696                    && let Some(err) = parsed.get("__error").and_then(|e| e.as_str())
697                {
698                    return fail(format!(
699                        "command '{}' returned error: {err}",
700                        params.command
701                    ));
702                }
703                if let Some(cursor) = log_cursor {
704                    return self.attach_backend_logs(&result, cursor);
705                }
706                CallToolResult::success(vec![ContentBlock::text(result)])
707            }
708            // Only a call that was SENT and then ran out of time can be a slow command; one that
709            // never left (a busy UI thread, a dead page) is not helped by a longer timeout hint.
710            Err(f)
711                if f.kind == EvalFailureKind::Aborted
712                    && f.message.starts_with("eval timed out") =>
713            {
714                fail(format!(
715                    "invoke_command failed: {} If the command is legitimately slow, pass \
716                     `timeout_ms` (up to {MAX_INVOKE_TIMEOUT_MS}).",
717                    f.message
718                ))
719            }
720            Err(f) => fail(format!("invoke_command failed: {}", f.message)),
721        }
722    }
723
724    #[tool(
725        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.",
726        annotations(
727            read_only_hint = true,
728            destructive_hint = false,
729            idempotent_hint = true,
730            open_world_hint = false
731        )
732    )]
733    async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
734        if !self.state.privacy.is_tool_enabled("screenshot") {
735            return tool_disabled("screenshot");
736        }
737        // Resolve the EXACT visible window BEFORE touching the OS handle — a silent
738        // wrong-window image is worse than a clear failure (see the helper for the history).
739        let target_label = match self.resolve_visible_capture_target(params.window_label.as_deref())
740        {
741            Ok(label) => label,
742            Err(e) => return tool_error(e),
743        };
744        match self.bridge.get_native_handle(Some(&target_label)) {
745            Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
746                Ok(png_bytes) => {
747                    use base64::Engine;
748                    let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
749                    CallToolResult::success(vec![ContentBlock::image(b64, "image/png")])
750                }
751                Err(e) => tool_error(format!("screenshot capture failed: {e}")),
752            },
753            Err(e) => tool_error(format!("cannot get window handle: {e}")),
754        }
755    }
756
757    #[tool(
758        description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.",
759        annotations(
760            read_only_hint = false,
761            destructive_hint = true,
762            idempotent_hint = false,
763            open_world_hint = false
764        )
765    )]
766    async fn verify_state(
767        &self,
768        Parameters(params): Parameters<VerifyStateParams>,
769    ) -> CallToolResult {
770        if !self.state.privacy.is_tool_enabled("eval_js") {
771            return tool_disabled("verify_state requires eval_js capability");
772        }
773        let code = expression_eval_code(&params.frontend_expr);
774        let frontend_json = match self
775            .eval_with_return(&code, params.webview_label.as_deref())
776            .await
777        {
778            Ok(result) => result,
779            Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
780        };
781
782        let frontend_state: serde_json::Value = match parse_expression_value(&frontend_json) {
783            Ok(v) => v,
784            Err(e) => {
785                return tool_error(format!(
786                    "frontend expression did not return valid JSON: {e}"
787                ));
788            }
789        };
790
791        let backend_state = if let Some(state) = params.backend_state {
792            state
793        } else if let Some(ref cmd) = params.backend_command {
794            // Gate on BOTH is_invoke_allowed and is_command_allowed, matching
795            // invoke_command and the contract/replay paths — backend_command
796            // previously checked only the blocklist (audit #30 follow-up).
797            if !self.state.privacy.is_invoke_allowed(cmd)
798                || !self.state.privacy.is_command_allowed(cmd)
799            {
800                return tool_error(format!(
801                    "command '{cmd}' is blocked by privacy configuration"
802                ));
803            }
804            let args = params.backend_args.unwrap_or(serde_json::json!({}));
805            let args_str = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
806            let invoke_code = format!(
807                "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
808                js_string(cmd)
809            );
810            match self
811                .eval_with_return(&invoke_code, params.webview_label.as_deref())
812                .await
813            {
814                Ok(result) => match serde_json::from_str(&result) {
815                    Ok(v) => v,
816                    Err(e) => {
817                        return tool_error(format!(
818                            "backend command '{cmd}' did not return valid JSON: {e}"
819                        ));
820                    }
821                },
822                Err(e) => {
823                    return tool_error(format!("failed to invoke backend command '{cmd}': {e}"));
824                }
825            }
826        } else {
827            return tool_error("either backend_state or backend_command must be provided");
828        };
829
830        let result = victauri_core::verify_state(frontend_state, backend_state);
831        json_result(&result)
832    }
833
834    #[tool(
835        description = "Detect ghost commands (frontend calls with no backend handler) by IPC OUTCOME, not by guessing from Victauri's registry. Returns: `confirmed_ghosts` = commands invoked that NEVER returned success and errored 'not found' — real missing-handler bugs, HIGH confidence and independent of whether the app uses #[inspectable]; `verified_handlers` = count of commands that returned success at least once (they provably HAVE a handler, so they are never flagged — this is why a real command like `set_language` is no longer a false positive); `frontend_only` = the WEAKER candidate tier (invoked, never observed succeeding, NOT a Tauri/plugin framework builtin, and absent from the introspection registry) — confirm against the app's `tauri::generate_handler!` before filing; `excluded_builtins` = framework `plugin:*` commands (never app ghosts); `registry_only` = registered commands never invoked (informational). The `reliability` field describes only `frontend_only`; `confirmed_ghosts` is high-confidence regardless. Reads the JS-side IPC interception log (ACCUMULATES all session traffic). For a clean signal scope with `since_ms` (e.g. 5000) — invoke the suspect action, then call this with `since_ms` — or `logs {action:'clear'}` then exercise the app. `ipc_capture` reports how complete the underlying IPC picture is (a `capture` other than `full` means calls may be missing).",
836        annotations(
837            read_only_hint = true,
838            destructive_hint = false,
839            idempotent_hint = true,
840            open_world_hint = false
841        )
842    )]
843    async fn detect_ghost_commands(
844        &self,
845        Parameters(params): Parameters<GhostCommandParams>,
846    ) -> CallToolResult {
847        // Project a per-command OUTCOME summary in JS ({command, ok, err}, deduped). Ghost
848        // detection is outcome-based (VIC-1): a command that returned success provably has a
849        // handler and is never a ghost; one that errored "not found" is a confirmed ghost.
850        // Aggregating per command keeps this tiny even on a busy app (avoids the eval cap).
851        // When `since_ms` is set, the projection time-windows to the current test's traffic.
852        let code = ghost_ipc_outcomes_js(params.since_ms);
853        let ipc_json = match self
854            .eval_with_return(&code, params.webview_label.as_deref())
855            .await
856        {
857            Ok(r) => r,
858            Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
859        };
860
861        let outcomes: Vec<crate::mcp::helpers::IpcOutcome> = match serde_json::from_str(&ipc_json) {
862            Ok(v) => v,
863            Err(e) => return tool_error(format!("failed to parse IPC log JSON: {e}")),
864        };
865
866        let mut report = build_ghost_report(&outcomes, &self.state.registry);
867        if let Some(obj) = report.as_object_mut() {
868            obj.insert(
869                "ipc_capture".to_string(),
870                self.ipc_capture_status(params.webview_label.as_deref())
871                    .await,
872            );
873        }
874        json_result(&report)
875    }
876
877    #[tool(
878        description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.",
879        annotations(
880            read_only_hint = true,
881            destructive_hint = false,
882            idempotent_hint = true,
883            open_world_hint = false
884        )
885    )]
886    async fn check_ipc_integrity(
887        &self,
888        Parameters(params): Parameters<IpcIntegrityParams>,
889    ) -> CallToolResult {
890        let threshold = params.stale_threshold_ms.unwrap_or(5000);
891        let code = ipc_integrity_js(threshold);
892        self.eval_bridge(&code, params.webview_label.as_deref())
893            .await
894    }
895
896    #[tool(
897        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), log (block until a backend Rust log entry containing `value` is captured, optional min `level`, `fields` to also match structured fields e.g. {\"run_type\":\"foreground\"} so a concurrent run of the same code path cannot satisfy it, `since_ms` look-back — wakes the instant it is logged). Use expression/event/log to await async backend work to true completion instead of guessing with a fixed sleep.",
898        annotations(
899            read_only_hint = false,
900            destructive_hint = true,
901            idempotent_hint = false,
902            open_world_hint = false
903        )
904    )]
905    async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
906        let timeout_ms = params.timeout_ms.unwrap_or(10_000).min(120_000);
907        // Clamp BEFORE the value reaches JS: a poll longer than the wait is meaningless, and
908        // one past 2^31 ms overflows `setTimeout` (which then fires immediately, in a loop).
909        let poll = params.poll_ms.unwrap_or(200).clamp(20, timeout_ms.max(20));
910
911        // The `expression` and `event` conditions are awaited server-side (they
912        // poll the eval engine and the captured event bus respectively), so a
913        // fire-and-forget backend command can be awaited to true completion.
914        match params.condition {
915            WaitCondition::Expression => {
916                return self.wait_for_expression(&params, timeout_ms, poll).await;
917            }
918            WaitCondition::Event => {
919                return self.wait_for_event(&params, timeout_ms, poll).await;
920            }
921            WaitCondition::Log => {
922                return self.wait_for_log(&params, timeout_ms, poll).await;
923            }
924            _ => {}
925        }
926
927        let value = params
928            .value
929            .as_ref()
930            .map_or_else(|| "null".to_string(), |v| js_string(v));
931        let code = format!(
932            "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
933            js_string(params.condition.as_str())
934        );
935        // `waitFor` starts its own `timeout_ms` clock only once the code runs, while the eval's
936        // timeout covers the whole call — so the slack must also cover SENDING the code on a busy
937        // UI thread (the probe AND the injection are main-thread round trips that may each wait
938        // up to 10 s to start, plus the probe's 2 s answer window), or the condition's own
939        // answer would be cut off as an eval timeout.
940        let eval_timeout = std::time::Duration::from_millis(timeout_ms + WAIT_FOR_SEND_SLACK_MS);
941        match self
942            .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
943            .await
944        {
945            Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
946            Err(e) => tool_error(e),
947        }
948    }
949
950    /// Poll a JS expression until truthy (or `== expected`), server-side.
951    ///
952    /// Level-triggered and race-free: each poll re-evaluates the expression via
953    /// the same engine as `eval_js`, so it may `await`. Eval errors are treated
954    /// as "not yet met" (the target may not exist during startup) and the last
955    /// error is surfaced on timeout.
956    async fn wait_for_expression(
957        &self,
958        params: &WaitForParams,
959        timeout_ms: u64,
960        poll_ms: u64,
961    ) -> CallToolResult {
962        if !self.state.privacy.is_tool_enabled("eval_js") {
963            return tool_disabled("wait_for(expression) requires eval_js capability");
964        }
965        let Some(expr) = params.value.as_deref().filter(|s| !s.is_empty()) else {
966            return missing_param("value", "wait_for(expression)");
967        };
968        let code = expression_eval_code(expr);
969        let start = std::time::Instant::now();
970        let deadline = start + std::time::Duration::from_millis(timeout_ms);
971        let poll = std::time::Duration::from_millis(poll_ms);
972        let mut last_value = serde_json::Value::Null;
973        let mut last_error: Option<String> = None;
974
975        loop {
976            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
977            let per_eval = remaining
978                .min(std::time::Duration::from_secs(15))
979                .max(std::time::Duration::from_secs(1));
980            match self
981                .eval_outcome(&code, params.webview_label.as_deref(), per_eval)
982                .await
983            {
984                // Code that does not parse can never start matching: fail now instead of
985                // polling to the timeout.
986                Err(f) if f.is_parse_error() => {
987                    return tool_error(format!("wait_for(expression): {}", f.message));
988                }
989                Ok(raw) => {
990                    let val = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null);
991                    let met = match &params.expected {
992                        Some(expected) => &val == expected,
993                        None => json_truthy(&val),
994                    };
995                    if met {
996                        return json_result(&serde_json::json!({
997                            "ok": true,
998                            "value": val,
999                            "elapsed_ms": start.elapsed().as_millis() as u64,
1000                        }));
1001                    }
1002                    last_value = val;
1003                }
1004                Err(e) => last_error = Some(e.message),
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"),
1011                    "last_value": last_value,
1012                    "last_error": last_error,
1013                    "elapsed_ms": start.elapsed().as_millis() as u64,
1014                }));
1015            }
1016            tokio::time::sleep(
1017                poll.min(deadline.saturating_duration_since(std::time::Instant::now())),
1018            )
1019            .await;
1020        }
1021    }
1022
1023    /// Block until a named Tauri event appears on the captured event bus.
1024    ///
1025    /// Edge-triggered: matches the most recent event whose timestamp is no older
1026    /// than `since_ms` before this call began, so an event fired in the gap
1027    /// between `invoke_command` and this call is still caught. Polls the
1028    /// event-bus ring buffer — no webview eval involved.
1029    async fn wait_for_event(
1030        &self,
1031        params: &WaitForParams,
1032        timeout_ms: u64,
1033        poll_ms: u64,
1034    ) -> CallToolResult {
1035        let Some(name) = params.value.as_deref().filter(|s| !s.is_empty()) else {
1036            return missing_param("value", "wait_for(event)");
1037        };
1038        let since_ms = params.since_ms.unwrap_or(2000);
1039        let start = std::time::Instant::now();
1040        let baseline = bounded::ms_ago(chrono::Utc::now(), since_ms);
1041        let deadline = start + std::time::Duration::from_millis(timeout_ms);
1042        let poll = std::time::Duration::from_millis(poll_ms);
1043
1044        loop {
1045            // Search newest-first for a matching event no older than the baseline.
1046            let matched = self.state.event_bus.events().into_iter().rev().find(|e| {
1047                e.name == name
1048                    && chrono::DateTime::parse_from_rfc3339(&e.timestamp)
1049                        .map_or(true, |ts| ts.with_timezone(&chrono::Utc) >= baseline)
1050            });
1051            if let Some(ev) = matched {
1052                return json_result(&serde_json::json!({
1053                    "ok": true,
1054                    "event": {
1055                        "name": ev.name,
1056                        "payload": ev.payload,
1057                        "timestamp": ev.timestamp,
1058                    },
1059                    "elapsed_ms": start.elapsed().as_millis() as u64,
1060                }));
1061            }
1062            if std::time::Instant::now() >= deadline {
1063                return json_result(&serde_json::json!({
1064                    "ok": false,
1065                    "error": format!("timeout after {timeout_ms}ms waiting for event '{name}'"),
1066                    "hint": "Ensure the app emits this Tauri event and Victauri captures it: \
1067                             custom events need VictauriBuilder::listen_events(&[\"…\"]); \
1068                             window-lifecycle events are captured automatically.",
1069                    "elapsed_ms": start.elapsed().as_millis() as u64,
1070                }));
1071            }
1072            // Never sleep past the deadline (a full poll used to overshoot a short timeout).
1073            tokio::time::sleep(
1074                poll.min(deadline.saturating_duration_since(std::time::Instant::now())),
1075            )
1076            .await;
1077        }
1078    }
1079
1080    #[tool(
1081        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.",
1082        annotations(
1083            read_only_hint = false,
1084            destructive_hint = true,
1085            idempotent_hint = false,
1086            open_world_hint = false
1087        )
1088    )]
1089    async fn assert_semantic(
1090        &self,
1091        Parameters(params): Parameters<SemanticAssertParams>,
1092    ) -> CallToolResult {
1093        if !self.state.privacy.is_tool_enabled("eval_js") {
1094            return tool_disabled("assert_semantic requires eval_js capability");
1095        }
1096        let code = expression_eval_code(&params.expression);
1097        let actual_json = match self
1098            .eval_with_return(&code, params.webview_label.as_deref())
1099            .await
1100        {
1101            Ok(result) => result,
1102            Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
1103        };
1104
1105        let actual: serde_json::Value = match parse_expression_value(&actual_json) {
1106            Ok(v) => v,
1107            Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
1108        };
1109
1110        let assertion =
1111            victauri_core::SemanticAssertion::new(params.label, params.condition, params.expected);
1112
1113        let result = victauri_core::evaluate_assertion(actual, &assertion);
1114        json_result(&result)
1115    }
1116
1117    #[tool(
1118        description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.",
1119        annotations(
1120            read_only_hint = true,
1121            destructive_hint = false,
1122            idempotent_hint = true,
1123            open_world_hint = false
1124        )
1125    )]
1126    async fn resolve_command(
1127        &self,
1128        Parameters(params): Parameters<ResolveCommandParams>,
1129    ) -> CallToolResult {
1130        let limit = params.limit.unwrap_or(5);
1131        let mut results = self.state.registry.resolve(&params.query);
1132        results.truncate(limit);
1133        json_result(&results)
1134    }
1135
1136    #[tool(
1137        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.",
1138        annotations(
1139            read_only_hint = true,
1140            destructive_hint = false,
1141            idempotent_hint = true,
1142            open_world_hint = false
1143        )
1144    )]
1145    async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
1146        let commands = match params.query {
1147            Some(q) => self.state.registry.search(&q),
1148            None => self.state.registry.list(),
1149        };
1150        json_result(&commands)
1151    }
1152
1153    #[tool(
1154        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).",
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 app_state(&self, Parameters(params): Parameters<AppStateParams>) -> CallToolResult {
1163        let Some(name) = params.probe else {
1164            return json_result(&serde_json::json!({ "probes": self.state.probes.names() }));
1165        };
1166        if let Some(probe) = self.state.probes.get(&name) {
1167            // A probe is app code: run it off the async executor, bounded, and with a panic
1168            // boundary (it used to run inline on a tokio worker with neither). Its slot is
1169            // held by the probe's thread, so a hung probe that outlives the deadline still
1170            // counts against the cap and repeated calls cannot pile up leaked threads.
1171            let Ok(slot) = Arc::clone(&self.probe_slots).try_acquire_owned() else {
1172                return tool_error(format!(
1173                    "probe '{name}' not run: app probes are busy ({MAX_CONCURRENT_PROBES} \
1174                     still running — a probe that hangs keeps running past its timeout). \
1175                     Retry shortly."
1176                ));
1177            };
1178            match bounded::run_blocking_bounded(
1179                None,
1180                &format!("probe '{name}'"),
1181                self.probe_timeout,
1182                move || {
1183                    let _slot = slot;
1184                    Ok(probe())
1185                },
1186            )
1187            .await
1188            {
1189                Ok(value) => json_result(&value),
1190                Err(e) => tool_error(e),
1191            }
1192        } else {
1193            let available = self.state.probes.names();
1194            tool_error_with_hint(
1195                format!(
1196                    "unknown probe '{name}'. Available probes: {}",
1197                    if available.is_empty() {
1198                        "(none registered — add VictauriBuilder::probe(\"name\", ...))".to_string()
1199                    } else {
1200                        available.join(", ")
1201                    }
1202                ),
1203                RecoveryHint::CheckInput,
1204            )
1205        }
1206    }
1207
1208    #[tool(
1209        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.",
1210        annotations(
1211            read_only_hint = true,
1212            destructive_hint = false,
1213            idempotent_hint = true,
1214            open_world_hint = false
1215        )
1216    )]
1217    async fn get_memory_stats(&self) -> CallToolResult {
1218        let stats = crate::memory::current_stats();
1219        json_result(&stats)
1220    }
1221
1222    #[tool(
1223        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.",
1224        annotations(
1225            read_only_hint = true,
1226            destructive_hint = false,
1227            idempotent_hint = true,
1228            open_world_hint = false
1229        )
1230    )]
1231    async fn get_plugin_info(&self) -> CallToolResult {
1232        let disabled: Vec<&str> = self
1233            .state
1234            .privacy
1235            .disabled_tools
1236            .iter()
1237            .map(std::string::String::as_str)
1238            .collect();
1239        let blocklist: Vec<&str> = self
1240            .state
1241            .privacy
1242            .command_blocklist
1243            .iter()
1244            .map(std::string::String::as_str)
1245            .collect();
1246        let allowlist: Option<Vec<&str>> = self
1247            .state
1248            .privacy
1249            .command_allowlist
1250            .as_ref()
1251            .map(|s| s.iter().map(std::string::String::as_str).collect());
1252        let all_tools = Self::tool_router().list_all();
1253        let enabled_tools: Vec<&str> = all_tools
1254            .iter()
1255            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
1256            .map(|t| t.name.as_ref())
1257            .collect();
1258
1259        // Host-app identity: lets an agent verify on its FIRST call that it reached the
1260        // intended app (not another Victauri instance sharing the discovery port).
1261        let app_cfg = self.bridge.tauri_config();
1262        let result = serde_json::json!({
1263            "version": env!("CARGO_PKG_VERSION"),
1264            "bridge_version": BRIDGE_VERSION,
1265            "port": self.state.port.load(Ordering::Relaxed),
1266            "app": {
1267                "identifier": app_cfg.get("identifier"),
1268                "product_name": app_cfg.get("product_name"),
1269            },
1270            "tools": {
1271                "total": all_tools.len(),
1272                "enabled": enabled_tools.len(),
1273                "enabled_list": enabled_tools,
1274                "disabled_list": disabled,
1275            },
1276            "commands": {
1277                "allowlist": allowlist,
1278                "blocklist": blocklist,
1279            },
1280            "privacy": {
1281                "profile": self.state.privacy.profile.to_string(),
1282                "redaction_enabled": self.state.privacy.redaction_enabled,
1283            },
1284            "capacities": {
1285                "event_log": self.state.event_log.capacity(),
1286                "eval_timeout_secs": self.state.eval_timeout.as_secs(),
1287            },
1288            "registered_commands": self.state.registry.count(),
1289            "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
1290            "uptime_secs": self.state.started_at.elapsed().as_secs(),
1291        });
1292        json_result(&result)
1293    }
1294
1295    #[tool(
1296        description = "Run environment diagnostics: IPC capture completeness (`info.ipc_capture`: the Tauri IPC transport in use — fetch, or the postMessage fallback Tauri switches to after a custom-protocol failure — and whether capture is full, requests-only or none), service workers (break IPC interception), closed shadow DOM (invisible to snapshots), iframes (bridge absent), large DOM warnings, and CSP status. Call this first when connecting to an unfamiliar app.",
1297        annotations(
1298            read_only_hint = true,
1299            destructive_hint = false,
1300            idempotent_hint = true,
1301            open_world_hint = false
1302        )
1303    )]
1304    async fn get_diagnostics(
1305        &self,
1306        Parameters(params): Parameters<DiagnosticsParams>,
1307    ) -> CallToolResult {
1308        self.eval_bridge(
1309            "return window.__VICTAURI__?.getDiagnostics()",
1310            params.webview_label.as_deref(),
1311        )
1312        .await
1313    }
1314
1315    // ── Backend Access Tools ───────────────────────────────────────────────
1316
1317    #[tool(
1318        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.",
1319        annotations(
1320            read_only_hint = true,
1321            destructive_hint = false,
1322            idempotent_hint = true,
1323            open_world_hint = false
1324        )
1325    )]
1326    async fn app_info(&self) -> CallToolResult {
1327        let config = self.bridge.tauri_config();
1328
1329        let data_dir = self.bridge.app_data_dir().ok();
1330        let config_dir = self.bridge.app_config_dir().ok();
1331        let log_dir = self.bridge.app_log_dir().ok();
1332        let local_data_dir = self.bridge.app_local_data_dir().ok();
1333
1334        let env_vars: std::collections::BTreeMap<String, String> = std::env::vars()
1335            .filter(|(k, _)| is_safe_env_key(k))
1336            .collect();
1337
1338        // Enumerate every database candidate across ALL roots (configured db_search_paths
1339        // + every OS app dir), each tagged with size, whether it is a WebView/engine
1340        // internal store, and whether it is the one `query_db` would auto-select. This lets
1341        // an agent see and disambiguate the real app DB instead of guessing (audit /
1342        // red-team "wrong DB" finding — `app_info.databases` previously only walked
1343        // data_dir and returned bare relative names).
1344        #[cfg(feature = "sqlite")]
1345        let databases: Vec<serde_json::Value> = {
1346            let mut all_dirs: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
1347            for d in [
1348                data_dir.as_ref(),
1349                config_dir.as_ref(),
1350                log_dir.as_ref(),
1351                local_data_dir.as_ref(),
1352            ]
1353            .into_iter()
1354            .flatten()
1355            {
1356                all_dirs.push(d.clone());
1357            }
1358            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
1359                all_dirs.clone()
1360            } else {
1361                self.state.db_search_paths.clone()
1362            };
1363            // A directory walk (a slow or network disk can take seconds): off the server's
1364            // async workers.
1365            tokio::task::spawn_blocking(move || {
1366                let selected = crate::database::select_app_database(&select_dirs).ok();
1367                crate::database::classify_databases(&all_dirs)
1368                    .into_iter()
1369                    .map(|c| {
1370                        serde_json::json!({
1371                            "path": c.path.to_string_lossy(),
1372                            "size_bytes": c.size_bytes,
1373                            "webview_internal": c.webview_internal,
1374                            "selected": selected.as_ref() == Some(&c.path),
1375                        })
1376                    })
1377                    .collect()
1378            })
1379            .await
1380            .unwrap_or_default()
1381        };
1382
1383        #[cfg(not(feature = "sqlite"))]
1384        let databases: Vec<serde_json::Value> = Vec::new();
1385
1386        let result = serde_json::json!({
1387            "config": config,
1388            "paths": {
1389                "data": data_dir.as_ref().map(|p| p.to_string_lossy()),
1390                "config": config_dir.as_ref().map(|p| p.to_string_lossy()),
1391                "log": log_dir.as_ref().map(|p| p.to_string_lossy()),
1392                "local_data": local_data_dir.as_ref().map(|p| p.to_string_lossy()),
1393            },
1394            "databases": databases,
1395            "env": env_vars,
1396            "process": {
1397                "pid": std::process::id(),
1398                "arch": std::env::consts::ARCH,
1399                "os": std::env::consts::OS,
1400                "family": std::env::consts::FAMILY,
1401            },
1402        });
1403        json_result(&result)
1404    }
1405
1406    #[tool(
1407        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.",
1408        annotations(
1409            read_only_hint = true,
1410            destructive_hint = false,
1411            idempotent_hint = true,
1412            open_world_hint = false
1413        )
1414    )]
1415    async fn list_app_dir(
1416        &self,
1417        Parameters(params): Parameters<ListAppDirParams>,
1418    ) -> CallToolResult {
1419        let base = match self.resolve_app_dir(params.directory) {
1420            Ok(d) => d,
1421            Err(e) => return tool_error(e),
1422        };
1423
1424        // Lexical traversal guard BEFORE any filesystem access: a `..` or absolute sub-path
1425        // is rejected as traversal up front.
1426        if let Some(sub) = params.path.as_deref()
1427            && let Err(e) = Self::lexical_safe(std::path::Path::new(sub))
1428        {
1429            return tool_error(e);
1430        }
1431        // Every filesystem call below is synchronous and unbounded in the directory's size, so
1432        // the whole walk runs on the blocking pool (it used to run on an async worker).
1433        let walk = tokio::task::spawn_blocking(move || Self::list_app_dir_blocking(&base, &params));
1434        match walk.await {
1435            Ok(Ok(listing)) => json_result(&listing),
1436            Ok(Err(e)) => tool_error(e),
1437            Err(e) => tool_error(format!("directory listing task failed: {e}")),
1438        }
1439    }
1440
1441    fn list_app_dir_blocking(
1442        base: &std::path::Path,
1443        params: &ListAppDirParams,
1444    ) -> Result<serde_json::Value, String> {
1445        let sub = params.path.clone().unwrap_or_default();
1446        let target = base.join(&sub);
1447        // A missing directory is a normal, non-error result — unless the path escapes the
1448        // base through a symlink, which is refused whether or not its target exists (so the
1449        // listing is no oracle for paths outside the base).
1450        if !Self::contained_or_missing(base, &target)? {
1451            return Ok(serde_json::json!({
1452                "base": base.to_string_lossy(),
1453                "path": sub,
1454                "exists": false,
1455                "entries": [],
1456                "count": 0,
1457            }));
1458        }
1459        let canon_base = std::fs::canonicalize(base)
1460            .map_err(|e| format!("cannot resolve base directory: {e}"))?;
1461
1462        let mut walk = DirWalk {
1463            canon_base,
1464            pattern: params.pattern.clone(),
1465            max_depth: params.max_depth.unwrap_or(1).min(5),
1466            deadline: std::time::Instant::now() + MAX_DIR_WALK_TIME,
1467            max_visited: MAX_DIR_VISITED,
1468            visited: 0,
1469            truncated: false,
1470            entries: Vec::new(),
1471        };
1472        walk.visit(&target, base, 0);
1473
1474        Ok(serde_json::json!({
1475            "base": base.to_string_lossy(),
1476            "path": sub,
1477            "exists": true,
1478            "count": walk.entries.len(),
1479            "entries": walk.entries,
1480            "truncated": walk.truncated,
1481        }))
1482    }
1483
1484    #[tool(
1485        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.",
1486        annotations(
1487            read_only_hint = true,
1488            destructive_hint = false,
1489            idempotent_hint = true,
1490            open_world_hint = false
1491        )
1492    )]
1493    async fn read_app_file(
1494        &self,
1495        Parameters(params): Parameters<ReadAppFileParams>,
1496    ) -> CallToolResult {
1497        let base = match self.resolve_app_dir(params.directory) {
1498            Ok(d) => d,
1499            Err(e) => return tool_error(e),
1500        };
1501
1502        // Lexical traversal guard FIRST — before the existence check — so a
1503        // traversal attempt (`..` / absolute) is rejected as traversal rather
1504        // than leaking whether the out-of-tree target exists via "file not
1505        // found". `safe_within` (which canonicalizes) stays below as
1506        // defense-in-depth for real files.
1507        if let Err(e) = Self::lexical_safe(std::path::Path::new(&params.path)) {
1508            return tool_error(e);
1509        }
1510        let target = base.join(&params.path);
1511        // Refused as traversal whether or not a symlinked-out target exists (no oracle).
1512        match Self::contained_or_missing(&base, &target) {
1513            Ok(true) => {}
1514            Ok(false) => return tool_error(format!("file not found: {}", params.path)),
1515            Err(e) => return tool_error(e),
1516        }
1517        if let Err(e) = Self::safe_within(&base, &target) {
1518            return tool_error(e);
1519        }
1520        if !target.is_file() {
1521            return tool_error(format!("not a file: {}", params.path));
1522        }
1523
1524        let max_bytes = params.max_bytes.unwrap_or(1_048_576).min(10_485_760);
1525
1526        // Open the CANONICAL validated path, and do the blocking file IO on the blocking pool.
1527        // Doing sync `std::fs` IO directly in this async fn could stall the executor thread if a
1528        // regular file were swapped (between the `safe_within` check and the open) for a FIFO or
1529        // slow device whose `read_to_end` blocks; opening the canonical path also closes the
1530        // trivial validate-lexical / open-lexical symlink-swap window.
1531        let canonical = match std::fs::canonicalize(&target) {
1532            Ok(c) => c,
1533            Err(e) => return tool_error(format!("cannot resolve path: {e}")),
1534        };
1535        let (mut bytes, original_size, modified) =
1536            match self.read_regular_file_bounded(canonical, max_bytes).await {
1537                Ok(v) => v,
1538                Err(e) => return tool_error(format!("failed to read file: {e}")),
1539            };
1540        let truncated = bytes.len() > max_bytes;
1541        if truncated {
1542            bytes.truncate(max_bytes);
1543        }
1544
1545        let file_info = serde_json::json!({
1546            "path": params.path,
1547            "size": original_size,
1548            "truncated": truncated,
1549            "modified": modified,
1550        });
1551
1552        if params.binary == Some(true) {
1553            use base64::Engine;
1554            let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1555            json_result(&serde_json::json!({
1556                "file": file_info,
1557                "encoding": "base64",
1558                "content": b64,
1559            }))
1560        } else {
1561            // Truncation can split a multi-byte character; that is not a non-UTF-8 file.
1562            // `error_len() == None` means the bytes end mid-character, so drop the partial
1563            // character instead of returning the whole read as base64.
1564            if truncated
1565                && let Err(e) = std::str::from_utf8(&bytes)
1566                && e.error_len().is_none()
1567            {
1568                bytes.truncate(e.valid_up_to());
1569            }
1570            match String::from_utf8(bytes) {
1571                Ok(text) => json_result(&serde_json::json!({
1572                    "file": file_info,
1573                    "encoding": "utf-8",
1574                    "content": text,
1575                })),
1576                Err(e) => {
1577                    use base64::Engine;
1578                    let bytes = e.into_bytes();
1579                    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1580                    json_result(&serde_json::json!({
1581                        "file": file_info,
1582                        "encoding": "base64",
1583                        "note": "file is not valid UTF-8, returning base64",
1584                        "content": b64,
1585                    }))
1586                }
1587            }
1588        }
1589    }
1590
1591    #[tool(
1592        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.",
1593        annotations(
1594            read_only_hint = true,
1595            destructive_hint = false,
1596            idempotent_hint = true,
1597            open_world_hint = false
1598        )
1599    )]
1600    async fn query_db(&self, Parameters(params): Parameters<QueryDbParams>) -> CallToolResult {
1601        // query_db is ALWAYS registered as a tool so the rmcp `#[tool_router]` macro
1602        // compiles with `default-features = false` (a consumer that drops the heavy
1603        // rusqlite C dependency). The actual SQLite implementation only exists with the
1604        // `sqlite` feature; without it, return a clear, actionable error.
1605        #[cfg(feature = "sqlite")]
1606        {
1607            self.query_db_impl(params).await
1608        }
1609        #[cfg(not(feature = "sqlite"))]
1610        {
1611            let _ = params;
1612            tool_error(
1613                "query_db is unavailable: this build was compiled without the 'sqlite' \
1614                 feature (default-features = false). Re-enable the 'sqlite' feature to use it.",
1615            )
1616        }
1617    }
1618
1619    /// Real `query_db` implementation — compiled only with the `sqlite` feature.
1620    #[cfg(feature = "sqlite")]
1621    async fn query_db_impl(&self, params: QueryDbParams) -> CallToolResult {
1622        // A refused query is refused for what it is, before any database is looked up: the
1623        // error used to depend on whether the app HAS a database ("no application database"
1624        // for a DELETE on an app without one), and it touched the filesystem for nothing.
1625        if let Err(e) = crate::database::validate_query(&params.query) {
1626            return tool_error(e);
1627        }
1628        let data_dir = match self.bridge.app_data_dir() {
1629            Ok(d) => d,
1630            Err(e) => return tool_error(format!("cannot access app data directory: {e}")),
1631        };
1632
1633        let search_dirs = self.db_roots();
1634
1635        let db_path = if let Some(ref requested_path) = params.path {
1636            match Self::resolve_existing_db_path(&search_dirs, requested_path) {
1637                Ok(path) => path,
1638                Err(e) => return tool_error(e),
1639            }
1640        } else {
1641            // Auto-select the application DB. When db_search_paths is configured it is
1642            // EXCLUSIVE — never fall back to OS app dirs (which hold WebView internals),
1643            // so a configured-but-empty root yields a clear error instead of silently
1644            // querying the wrong database. WebView/browser-engine internal stores are
1645            // excluded and the largest remaining candidate wins (audit / red-team "wrong
1646            // DB" finding).
1647            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
1648                search_dirs.clone()
1649            } else {
1650                self.state.db_search_paths.clone()
1651            };
1652            // A directory walk: off the server's async workers (see `app_info`).
1653            match tokio::task::spawn_blocking(move || {
1654                crate::database::select_app_database(&select_dirs)
1655            })
1656            .await
1657            {
1658                Ok(Ok(p)) => p,
1659                Ok(Err(e)) => return tool_error(e),
1660                Err(e) => return tool_error(format!("database discovery failed: {e}")),
1661            }
1662        };
1663
1664        let db_display = db_path
1665            .strip_prefix(&data_dir)
1666            .unwrap_or(&db_path)
1667            .to_string_lossy()
1668            .into_owned();
1669        let bind_params = params.params.unwrap_or_default();
1670        let query = params.query;
1671        let max_rows = params.max_rows;
1672
1673        match bounded::run_blocking_bounded(
1674            Some(&bounded::DB_SLOTS),
1675            "database query",
1676            crate::database::QUERY_TIMEOUT + bounded::BLOCKING_DEADLINE_SLACK,
1677            move || crate::database::query(&db_path, &query, &bind_params, max_rows),
1678        )
1679        .await
1680        {
1681            Ok(mut result) => {
1682                if let Some(obj) = result.as_object_mut() {
1683                    obj.insert("database".to_string(), serde_json::json!(db_display));
1684                }
1685                json_result(&result)
1686            }
1687            Err(e) => tool_error(e),
1688        }
1689    }
1690
1691    /// Roots a `query_db` / `db_health` database `path` resolves against, in precedence
1692    /// order: configured `db_search_paths` (so a configured app DB beats incidental ones such
1693    /// as `WebView` internals), then the app's data, config, local-data and log directories.
1694    /// De-duplicated keeping the FIRST occurrence: a relative path present under two roots
1695    /// always resolves to the same file (a `HashSet` made that nondeterministic), and both
1696    /// tools search the same places (`db_health` used to skip the log directory).
1697    #[cfg(feature = "sqlite")]
1698    fn db_roots(&self) -> Vec<std::path::PathBuf> {
1699        let mut roots: Vec<std::path::PathBuf> = Vec::new();
1700        let app_dirs = [
1701            self.bridge.app_data_dir(),
1702            self.bridge.app_config_dir(),
1703            self.bridge.app_local_data_dir(),
1704            self.bridge.app_log_dir(),
1705        ];
1706        let candidates = self
1707            .state
1708            .db_search_paths
1709            .iter()
1710            .cloned()
1711            .chain(app_dirs.into_iter().filter_map(Result::ok));
1712        for dir in candidates {
1713            if !roots.contains(&dir) {
1714                roots.push(dir);
1715            }
1716        }
1717        roots
1718    }
1719
1720    // ── Compound Tools ──────────────────────────────────────────────────────
1721
1722    #[tool(
1723        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].",
1724        annotations(
1725            read_only_hint = false,
1726            destructive_hint = false,
1727            idempotent_hint = false,
1728            open_world_hint = false
1729        )
1730    )]
1731    async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
1732        if !self.state.privacy.is_tool_enabled("interact") {
1733            return tool_disabled("interact");
1734        }
1735        match params.action {
1736            InteractAction::Click => {
1737                if !self.state.privacy.is_tool_enabled("interact.click") {
1738                    return tool_disabled("interact.click");
1739                }
1740                let Some(ref_id) = &params.ref_id else {
1741                    return missing_param("ref_id", "click");
1742                };
1743                if params.trusted.unwrap_or(false) {
1744                    // Resolve the click point in the top window's viewport (frame offsets
1745                    // added), refusing a disabled/hidden/covered/off-screen element — a real
1746                    // OS click lands on whatever is on screen there — then deliver it.
1747                    let probe = trusted_click_probe_js(ref_id);
1748                    let raw = match self
1749                        .eval_with_return(&probe, params.webview_label.as_deref())
1750                        .await
1751                    {
1752                        Ok(r) => r,
1753                        Err(e) => return tool_error(e),
1754                    };
1755                    let point = serde_json::from_str::<serde_json::Value>(&raw).unwrap_or_default();
1756                    if let Some(why) = point.get("error").and_then(serde_json::Value::as_str) {
1757                        return tool_error_with_hint(
1758                            format!(
1759                                "trusted click on {ref_id} refused: {why} — no OS click was sent"
1760                            ),
1761                            RecoveryHint::CheckInput,
1762                        );
1763                    }
1764                    let (Some(x), Some(y)) = (
1765                        point.get("x").and_then(serde_json::Value::as_f64),
1766                        point.get("y").and_then(serde_json::Value::as_f64),
1767                    ) else {
1768                        return tool_error_with_hint(
1769                            format!("ref not found: {ref_id}"),
1770                            RecoveryHint::CheckInput,
1771                        );
1772                    };
1773                    if !(x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0) {
1774                        return tool_error_with_hint(
1775                            format!(
1776                                "trusted click on {ref_id} refused: the page reported an \
1777                                 unusable click point ({x}, {y}) — no OS click was sent"
1778                            ),
1779                            RecoveryHint::CheckInput,
1780                        );
1781                    }
1782                    let bridge = self.bridge.clone();
1783                    let label = params.webview_label.clone();
1784                    let native = tokio::task::spawn_blocking(move || {
1785                        bridge.native_click(label.as_deref(), x, y)
1786                    })
1787                    .await
1788                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1789                    return match native {
1790                        Ok(()) => json_result(
1791                            &serde_json::json!({"ok": true, "trusted": true, "x": x, "y": y}),
1792                        ),
1793                        Err(e) => tool_error(e),
1794                    };
1795                }
1796                let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
1797                self.eval_page_action(&code, params.webview_label.as_deref())
1798                    .await
1799            }
1800            InteractAction::DoubleClick => {
1801                if !self.state.privacy.is_tool_enabled("interact.double_click") {
1802                    return tool_disabled("interact.double_click");
1803                }
1804                let Some(ref_id) = &params.ref_id else {
1805                    return missing_param("ref_id", "double_click");
1806                };
1807                let code = format!(
1808                    "return window.__VICTAURI__?.doubleClick({})",
1809                    js_string(ref_id)
1810                );
1811                self.eval_page_action(&code, params.webview_label.as_deref())
1812                    .await
1813            }
1814            InteractAction::Hover => {
1815                if !self.state.privacy.is_tool_enabled("interact.hover") {
1816                    return tool_disabled("interact.hover");
1817                }
1818                let Some(ref_id) = &params.ref_id else {
1819                    return missing_param("ref_id", "hover");
1820                };
1821                let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
1822                self.eval_page_action(&code, params.webview_label.as_deref())
1823                    .await
1824            }
1825            InteractAction::Focus => {
1826                if !self.state.privacy.is_tool_enabled("interact.focus") {
1827                    return tool_disabled("interact.focus");
1828                }
1829                let Some(ref_id) = &params.ref_id else {
1830                    return missing_param("ref_id", "focus");
1831                };
1832                let code = format!(
1833                    "return window.__VICTAURI__?.focusElement({})",
1834                    js_string(ref_id)
1835                );
1836                self.eval_page_action(&code, params.webview_label.as_deref())
1837                    .await
1838            }
1839            InteractAction::ScrollIntoView => {
1840                if !self
1841                    .state
1842                    .privacy
1843                    .is_tool_enabled("interact.scroll_into_view")
1844                {
1845                    return tool_disabled("interact.scroll_into_view");
1846                }
1847                let ref_arg = params
1848                    .ref_id
1849                    .as_ref()
1850                    .map_or_else(|| "null".to_string(), |r| js_string(r));
1851                let x = params.x.unwrap_or(0.0);
1852                let y = params.y.unwrap_or(0.0);
1853                let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
1854                self.eval_page_action(&code, params.webview_label.as_deref())
1855                    .await
1856            }
1857            InteractAction::SelectOption => {
1858                if !self.state.privacy.is_tool_enabled("interact.select_option") {
1859                    return tool_disabled("interact.select_option");
1860                }
1861                let Some(ref_id) = &params.ref_id else {
1862                    return missing_param("ref_id", "select_option");
1863                };
1864                let values_vec;
1865                let values: &[String] = match (&params.values, &params.value) {
1866                    (Some(v), _) => v,
1867                    (None, Some(v)) => {
1868                        values_vec = vec![v.clone()];
1869                        &values_vec
1870                    }
1871                    (None, None) => &[],
1872                };
1873                let values_json =
1874                    serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
1875                let code = format!(
1876                    "return window.__VICTAURI__?.selectOption({}, {})",
1877                    js_string(ref_id),
1878                    values_json
1879                );
1880                self.eval_page_action(&code, params.webview_label.as_deref())
1881                    .await
1882            }
1883        }
1884    }
1885
1886    #[tool(
1887        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].",
1888        annotations(
1889            read_only_hint = false,
1890            destructive_hint = false,
1891            idempotent_hint = false,
1892            open_world_hint = false
1893        )
1894    )]
1895    async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
1896        match params.action {
1897            InputAction::Fill => {
1898                if !self.state.privacy.is_tool_enabled("fill") {
1899                    return tool_disabled("fill");
1900                }
1901                let Some(ref_id) = &params.ref_id else {
1902                    return missing_param("ref_id", "fill");
1903                };
1904                let Some(value) = &params.value else {
1905                    return missing_param("value", "fill");
1906                };
1907                let code = format!(
1908                    "return window.__VICTAURI__?.fill({}, {})",
1909                    js_string(ref_id),
1910                    js_string(value)
1911                );
1912                self.eval_page_action(&code, params.webview_label.as_deref())
1913                    .await
1914            }
1915            InputAction::TypeText => {
1916                if !self.state.privacy.is_tool_enabled("type_text") {
1917                    return tool_disabled("type_text");
1918                }
1919                let Some(ref_id) = &params.ref_id else {
1920                    return missing_param("ref_id", "type_text");
1921                };
1922                let Some(text) = &params.text else {
1923                    return missing_param("text", "type_text");
1924                };
1925                if params.trusted.unwrap_or(false) {
1926                    // Focus the element via JS — and confirm focus landed on it, since the OS
1927                    // keystrokes go to whatever holds focus — then deliver real OS keystrokes
1928                    // (isTrusted: true) for handlers that reject synthetic events.
1929                    if let Err(refused) = self
1930                        .focus_for_trusted_input(ref_id, params.webview_label.as_deref())
1931                        .await
1932                    {
1933                        return refused;
1934                    }
1935                    let bridge = self.bridge.clone();
1936                    let label = params.webview_label.clone();
1937                    let text = text.to_string();
1938                    let native = tokio::task::spawn_blocking(move || {
1939                        bridge.native_type_text(label.as_deref(), &text)
1940                    })
1941                    .await
1942                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1943                    return match native {
1944                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1945                        Err(e) => tool_error(e),
1946                    };
1947                }
1948                let code = format!(
1949                    "return window.__VICTAURI__?.type({}, {})",
1950                    js_string(ref_id),
1951                    js_string(text)
1952                );
1953                self.eval_page_action(&code, params.webview_label.as_deref())
1954                    .await
1955            }
1956            InputAction::PressKey => {
1957                if !self.state.privacy.is_tool_enabled("input.press_key") {
1958                    return tool_disabled("input.press_key");
1959                }
1960                let Some(key) = &params.key else {
1961                    return missing_param("key", "press_key");
1962                };
1963                if params.trusted.unwrap_or(false) {
1964                    // Optionally focus a target element, then send a real OS key. A failed focus
1965                    // must stop here: the key would otherwise go to whatever holds focus.
1966                    if let Some(ref_id) = &params.ref_id
1967                        && let Err(refused) = self
1968                            .focus_for_trusted_input(ref_id, params.webview_label.as_deref())
1969                            .await
1970                    {
1971                        return refused;
1972                    }
1973                    let bridge = self.bridge.clone();
1974                    let label = params.webview_label.clone();
1975                    let key = key.to_string();
1976                    let native = tokio::task::spawn_blocking(move || {
1977                        bridge.native_key(label.as_deref(), &key)
1978                    })
1979                    .await
1980                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1981                    return match native {
1982                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1983                        Err(e) => tool_error(e),
1984                    };
1985                }
1986                let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
1987                self.eval_page_action(&code, params.webview_label.as_deref())
1988                    .await
1989            }
1990        }
1991    }
1992
1993    #[tool(
1994        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).",
1995        annotations(
1996            read_only_hint = false,
1997            destructive_hint = true,
1998            idempotent_hint = false,
1999            open_world_hint = false
2000        )
2001    )]
2002    async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
2003        match params.action {
2004            WindowAction::GetState => {
2005                let states = match self.bridge.try_get_window_states(params.label.as_deref()) {
2006                    Ok(states) => states,
2007                    Err(e) => return tool_error(ui_busy(&e)),
2008                };
2009                // A specific label that matches no window is an error, not an
2010                // empty array (which reads as "success, no state").
2011                if states.is_empty()
2012                    && let Some(label) = params.label.as_deref()
2013                {
2014                    return tool_error(format!(
2015                        "window not found: '{label}' (use window.list to see available labels)"
2016                    ));
2017                }
2018                json_result(&states)
2019            }
2020            WindowAction::List => match self.bridge.try_list_window_labels() {
2021                Ok(labels) => json_result(&labels),
2022                Err(e) => tool_error(ui_busy(&e)),
2023            },
2024            WindowAction::Introspectability => self.window_introspectability().await,
2025            WindowAction::Manage => {
2026                if !self.state.privacy.is_tool_enabled("window.manage") {
2027                    return tool_disabled("window.manage");
2028                }
2029                let Some(manage_action) = &params.manage_action else {
2030                    return missing_param("manage_action", "manage");
2031                };
2032                match self
2033                    .bridge
2034                    .manage_window(params.label.as_deref(), manage_action.as_str())
2035                {
2036                    Ok(msg) => CallToolResult::success(vec![ContentBlock::text(msg)]),
2037                    Err(e) => tool_error(e),
2038                }
2039            }
2040            WindowAction::Resize => {
2041                if !self.state.privacy.is_tool_enabled("window.resize") {
2042                    return tool_disabled("window.resize");
2043                }
2044                let Some(width) = params.width else {
2045                    return missing_param("width", "resize");
2046                };
2047                let Some(height) = params.height else {
2048                    return missing_param("height", "resize");
2049                };
2050                if width == 0 || height == 0 {
2051                    return tool_error_with_hint(
2052                        format!(
2053                            "invalid window size {width}x{height}: width and height must be > 0"
2054                        ),
2055                        RecoveryHint::CheckInput,
2056                    );
2057                }
2058                match self
2059                    .bridge
2060                    .resize_window(params.label.as_deref(), width, height)
2061                {
2062                    Ok(()) => {
2063                        let result =
2064                            serde_json::json!({"ok": true, "width": width, "height": height});
2065                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2066                    }
2067                    Err(e) => tool_error(e),
2068                }
2069            }
2070            WindowAction::MoveTo => {
2071                if !self.state.privacy.is_tool_enabled("window.move_to") {
2072                    return tool_disabled("window.move_to");
2073                }
2074                let Some(x) = params.x else {
2075                    return missing_param("x", "move_to");
2076                };
2077                let Some(y) = params.y else {
2078                    return missing_param("y", "move_to");
2079                };
2080                match self.bridge.move_window(params.label.as_deref(), x, y) {
2081                    Ok(()) => {
2082                        let result = serde_json::json!({"ok": true, "x": x, "y": y});
2083                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2084                    }
2085                    Err(e) => tool_error(e),
2086                }
2087            }
2088            WindowAction::SetTitle => {
2089                if !self.state.privacy.is_tool_enabled("window.set_title") {
2090                    return tool_disabled("window.set_title");
2091                }
2092                let Some(title) = &params.title else {
2093                    return missing_param("title", "set_title");
2094                };
2095                match self.bridge.set_window_title(params.label.as_deref(), title) {
2096                    Ok(()) => {
2097                        let result = serde_json::json!({"ok": true, "title": title});
2098                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2099                    }
2100                    Err(e) => tool_error(e),
2101                }
2102            }
2103        }
2104    }
2105
2106    #[tool(
2107        description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
2108        annotations(
2109            read_only_hint = false,
2110            destructive_hint = true,
2111            idempotent_hint = false,
2112            open_world_hint = false
2113        )
2114    )]
2115    async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
2116        match params.action {
2117            StorageAction::Get => {
2118                let method = match params.storage_type.unwrap_or(StorageType::Local) {
2119                    StorageType::Session => "getSessionStorage",
2120                    StorageType::Local => "getLocalStorage",
2121                };
2122                let key_arg = params
2123                    .key
2124                    .as_ref()
2125                    .map(|k| js_string(k))
2126                    .unwrap_or_default();
2127                let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
2128                self.eval_bridge(&code, params.webview_label.as_deref())
2129                    .await
2130            }
2131            StorageAction::Set => {
2132                if !self.state.privacy.is_tool_enabled("set_storage") {
2133                    return tool_disabled("set_storage");
2134                }
2135                let method = match params.storage_type.unwrap_or(StorageType::Local) {
2136                    StorageType::Session => "setSessionStorage",
2137                    StorageType::Local => "setLocalStorage",
2138                };
2139                let Some(key) = &params.key else {
2140                    return missing_param("key", "set");
2141                };
2142                // Operator-protected keys (auth/role/tier/flags) can't be poisoned
2143                // via storage.set (audit #33).
2144                if !self.state.privacy.is_storage_key_allowed(key) {
2145                    return tool_error(format!(
2146                        "storage key '{key}' is protected by privacy configuration"
2147                    ));
2148                }
2149                let value = params
2150                    .value
2151                    .as_ref()
2152                    .cloned()
2153                    .unwrap_or(serde_json::Value::Null);
2154                let value_json =
2155                    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
2156                let code = format!(
2157                    "return window.__VICTAURI__?.{method}({}, {value_json})",
2158                    js_string(key)
2159                );
2160                self.eval_bridge(&code, params.webview_label.as_deref())
2161                    .await
2162            }
2163            StorageAction::Delete => {
2164                if !self.state.privacy.is_tool_enabled("delete_storage") {
2165                    return tool_disabled("delete_storage");
2166                }
2167                let method = match params.storage_type.unwrap_or(StorageType::Local) {
2168                    StorageType::Session => "deleteSessionStorage",
2169                    StorageType::Local => "deleteLocalStorage",
2170                };
2171                let Some(key) = &params.key else {
2172                    return missing_param("key", "delete");
2173                };
2174                let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
2175                self.eval_bridge(&code, params.webview_label.as_deref())
2176                    .await
2177            }
2178            StorageAction::GetCookies => {
2179                self.eval_bridge(
2180                    "return window.__VICTAURI__?.getCookies()",
2181                    params.webview_label.as_deref(),
2182                )
2183                .await
2184            }
2185        }
2186    }
2187
2188    #[tool(
2189        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.",
2190        annotations(
2191            read_only_hint = false,
2192            destructive_hint = false,
2193            idempotent_hint = false,
2194            open_world_hint = false
2195        )
2196    )]
2197    async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
2198        match params.action {
2199            NavigateAction::GoTo => {
2200                if !self.state.privacy.is_tool_enabled("navigate") {
2201                    return tool_disabled("navigate");
2202                }
2203                let Some(url) = &params.url else {
2204                    return missing_param("url", "go_to");
2205                };
2206                if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
2207                    return tool_error(e);
2208                }
2209                let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
2210                self.eval_bridge(&code, params.webview_label.as_deref())
2211                    .await
2212            }
2213            NavigateAction::GoBack => {
2214                self.eval_bridge(
2215                    "return window.__VICTAURI__?.navigateBack()",
2216                    params.webview_label.as_deref(),
2217                )
2218                .await
2219            }
2220            NavigateAction::GetHistory => {
2221                self.eval_bridge(
2222                    "return window.__VICTAURI__?.getNavigationLog()",
2223                    params.webview_label.as_deref(),
2224                )
2225                .await
2226            }
2227            NavigateAction::SetDialogResponse => {
2228                if !self.state.privacy.is_tool_enabled("set_dialog_response") {
2229                    return tool_disabled("set_dialog_response");
2230                }
2231                let Some(dialog_type) = params.dialog_type else {
2232                    return missing_param("dialog_type", "set_dialog_response");
2233                };
2234                let Some(dialog_action) = params.dialog_action else {
2235                    return missing_param("dialog_action", "set_dialog_response");
2236                };
2237                let text_arg = params
2238                    .text
2239                    .as_ref()
2240                    .map_or_else(|| "undefined".to_string(), |t| js_string(t));
2241                let code = format!(
2242                    "return {}?.setDialogAutoResponse({}, {}, {text_arg})",
2243                    crate::js_bridge::agent_ops_js(),
2244                    js_string(dialog_type.as_str()),
2245                    js_string(dialog_action.as_str())
2246                );
2247                self.eval_bridge(&code, params.webview_label.as_deref())
2248                    .await
2249            }
2250            NavigateAction::GetDialogLog => {
2251                self.eval_bridge(
2252                    "return window.__VICTAURI__?.getDialogLog()",
2253                    params.webview_label.as_deref(),
2254                )
2255                .await
2256            }
2257        }
2258    }
2259
2260    #[tool(
2261        description = "Time-travel recording. Actions: start (begin recording), stop (end and return session), checkpoint (save state snapshot), list_checkpoints, get_events (since index), events_between (two checkpoints), get_replay (IPC replay sequence), export (session as JSON), import (load a session from JSON as the active recording; refused while one is in progress), replay (re-invoke the recorded IPC commands that succeeded with no arguments, each in the window that made it — their side effects happen again; calls that had arguments, failed, never completed, were answered by a route rule, or were captured on Tauri's postMessage IPC path are skipped, and webview_label limits replay to that window's calls), flush (immediately drain pending events into recording without waiting for the 1-second poll).",
2262        annotations(
2263            read_only_hint = false,
2264            destructive_hint = true,
2265            idempotent_hint = false,
2266            open_world_hint = false
2267        )
2268    )]
2269    async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
2270        const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
2271        if !self.state.privacy.is_tool_enabled("recording") {
2272            return tool_disabled("recording");
2273        }
2274        match params.action {
2275            RecordingAction::Start => {
2276                let session_id = params
2277                    .session_id
2278                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
2279                let floor_ms = now_ms();
2280                match self.state.recorder.start_session(session_id.clone()) {
2281                    Ok(generation) => {
2282                        self.state.drain_watermarks.reset(floor_ms, generation);
2283                        let result = serde_json::json!({
2284                            "started": true,
2285                            "session_id": session_id,
2286                        });
2287                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2288                    }
2289                    Err(e) => tool_error(e.to_string()),
2290                }
2291            }
2292            RecordingAction::Stop => {
2293                // Final flush first: the background drain reads each window about once a second, so
2294                // anything the page captured since its last tick would otherwise be lost — and under
2295                // a busy UI no drain may have run at all. Best-effort and bounded; a window that
2296                // cannot answer in time is REPORTED, never silently dropped.
2297                let unreachable = if self.state.recorder.is_recording() {
2298                    self.final_recording_flush().await
2299                } else {
2300                    Vec::new()
2301                };
2302                match self.state.recorder.stop() {
2303                    Some(session) => {
2304                        if unreachable.is_empty() {
2305                            json_result(&session)
2306                        } else {
2307                            let mut value =
2308                                serde_json::to_value(&session).unwrap_or(serde_json::Value::Null);
2309                            if let Some(obj) = value.as_object_mut() {
2310                                obj.insert(
2311                                    "final_flush_unreachable".to_string(),
2312                                    serde_json::json!(unreachable),
2313                                );
2314                            }
2315                            json_result(&value)
2316                        }
2317                    }
2318                    None => tool_error("no recording is active"),
2319                }
2320            }
2321            RecordingAction::Checkpoint => {
2322                // checkpoint_id is optional — auto-generate a short id when the
2323                // caller just wants a positional marker. The id is echoed back in
2324                // the response so it can be referenced later
2325                // (events_between_checkpoints / replay).
2326                let id = params
2327                    .checkpoint_id
2328                    .unwrap_or_else(|| format!("cp-{}", uuid::Uuid::new_v4()));
2329                let state = params.state.unwrap_or(serde_json::Value::Null);
2330                match self
2331                    .state
2332                    .recorder
2333                    .checkpoint(id.clone(), params.checkpoint_label, state)
2334                {
2335                    Ok(()) => {
2336                        let result = serde_json::json!({
2337                            "created": true,
2338                            "checkpoint_id": id,
2339                            "event_index": self.state.recorder.event_count(),
2340                        });
2341                        CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2342                    }
2343                    Err(e) => tool_error(e.to_string()),
2344                }
2345            }
2346            RecordingAction::ListCheckpoints => {
2347                let checkpoints = self.state.recorder.get_checkpoints();
2348                json_result(&checkpoints)
2349            }
2350            RecordingAction::GetEvents => {
2351                let events = self
2352                    .state
2353                    .recorder
2354                    .events_since(params.since_index.unwrap_or(0));
2355                json_result(&events)
2356            }
2357            RecordingAction::EventsBetween => {
2358                let Some(from) = &params.from else {
2359                    return missing_param("from", "events_between");
2360                };
2361                let Some(to) = &params.to else {
2362                    return missing_param("to", "events_between");
2363                };
2364                match self.state.recorder.events_between_checkpoints(from, to) {
2365                    Ok(events) => json_result(&events),
2366                    Err(e) => tool_error(e.to_string()),
2367                }
2368            }
2369            RecordingAction::GetReplay => {
2370                let calls = self.state.recorder.ipc_replay_sequence();
2371                json_result(&calls)
2372            }
2373            RecordingAction::Export => match self.state.recorder.export() {
2374                Some(s) => {
2375                    let json = serde_json::to_string_pretty(&s)
2376                        .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
2377                    CallToolResult::success(vec![ContentBlock::text(json)])
2378                }
2379                None => tool_error("no recording is active — start one first"),
2380            },
2381            RecordingAction::Import => {
2382                let Some(session_json) = &params.session_json else {
2383                    return missing_param("session_json", "import");
2384                };
2385                if session_json.len() > MAX_SESSION_JSON {
2386                    return tool_error("session JSON exceeds maximum size (10 MB)");
2387                }
2388                let session: victauri_core::RecordedSession =
2389                    match serde_json::from_str(session_json) {
2390                        Ok(s) => s,
2391                        Err(e) => return tool_error(format!("invalid session JSON: {e}")),
2392                    };
2393
2394                let result = serde_json::json!({
2395                    "imported": true,
2396                    "session_id": session.id,
2397                    "event_count": session.events.len(),
2398                    "checkpoint_count": session.checkpoints.len(),
2399                    "started_at": session.started_at.to_rfc3339(),
2400                    "recording_active": true,
2401                    "note": "the imported session is now the ACTIVE recording: live events are \
2402                             appended to it until you call recording action=stop",
2403                });
2404                // Import REPLACES the active recording. Doing that silently threw away an
2405                // in-progress recording, so require it to be stopped (and saved) first — checked
2406                // and replaced in one step, so a recording started concurrently is never lost.
2407                let floor_ms = now_ms();
2408                match self.state.recorder.import_if_idle(session) {
2409                    Ok(generation) => self.state.drain_watermarks.reset(floor_ms, generation),
2410                    Err(_) => {
2411                        return tool_error(
2412                            "a recording is in progress — import would discard it. Stop it first \
2413                             (recording action=stop, or export it), then import.",
2414                        );
2415                    }
2416                }
2417                CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2418            }
2419            RecordingAction::Flush => {
2420                if !self.state.recorder.is_recording() {
2421                    return tool_error("no active recording — start a recording first");
2422                }
2423                // Same routine (and the same shared per-window watermark + lock) as the
2424                // background drain, so a flush never re-records what the drain already captured
2425                // and vice versa. With no label, every live window is flushed.
2426                let labels: Vec<String> = match params.webview_label.as_deref() {
2427                    Some(l) => vec![l.to_string()],
2428                    None => match self.bridge.try_list_window_labels() {
2429                        Ok(labels) => labels,
2430                        Err(e) => return tool_error(ui_busy(&e)),
2431                    },
2432                };
2433                let mut captured = 0usize;
2434                let mut failed = Vec::new();
2435                for label in &labels {
2436                    match crate::mcp::server::drain_window_into_recording(
2437                        &self.state,
2438                        &self.bridge,
2439                        label,
2440                    )
2441                    .await
2442                    {
2443                        Some(n) => captured += n,
2444                        None => failed.push(label.clone()),
2445                    }
2446                }
2447                if !labels.is_empty() && failed.len() == labels.len() {
2448                    return tool_error(format!(
2449                        "flush failed: no window answered ({})",
2450                        failed.join(", ")
2451                    ));
2452                }
2453                json_result(&serde_json::json!({
2454                    "flushed": true,
2455                    "events_captured": captured,
2456                    "windows": labels,
2457                    "unreachable_windows": failed,
2458                }))
2459            }
2460            RecordingAction::Replay => {
2461                let calls = self.state.recorder.ipc_replay_sequence();
2462                if calls.is_empty() {
2463                    return tool_error("no IPC calls recorded — record a session first");
2464                }
2465                let mut replay_results = Vec::new();
2466                for call in &calls {
2467                    // Recordings do not capture call ARGUMENTS, so a replay re-invokes each
2468                    // command with none. Re-running a call that took arguments is guaranteed
2469                    // wrong (it fails, or worse, runs with defaults), and re-running a call
2470                    // that failed or never completed reproduces nothing — skip both, loudly.
2471                    // A call a route rule answered in the page never reached the backend:
2472                    // replaying it would turn a fake (possibly page-forged) success into a real
2473                    // invocation.
2474                    let skip_reason = match &call.result {
2475                        _ if call.mocked => Some(
2476                            "the original call was answered by a route rule in the page, not the backend".to_string(),
2477                        ),
2478                        // Observed on Tauri's postMessage path: the bridge saw the call's
2479                        // serialization and response callback, which page script can stage
2480                        // without a real call, so its delivery is unproven.
2481                        _ if call.is_post_message() => Some(
2482                            "the original call was captured on Tauri's postMessage IPC path, where the bridge cannot prove it was really sent".to_string(),
2483                        ),
2484                        victauri_core::IpcResult::Ok(_) if call.arg_size_bytes > 0 => Some(
2485                            "the original call had arguments, which recordings do not capture".to_string(),
2486                        ),
2487                        victauri_core::IpcResult::Ok(_) => params
2488                            .webview_label
2489                            .as_deref()
2490                            .filter(|only| *only != call.webview_label)
2491                            .map(|only| {
2492                                format!(
2493                                    "recorded in window '{}'; replay was limited to '{only}'",
2494                                    call.webview_label
2495                                )
2496                            }),
2497                        victauri_core::IpcResult::Err(_) => {
2498                            Some("the original call failed".to_string())
2499                        }
2500                        _ => Some("the original call never completed".to_string()),
2501                    };
2502                    if let Some(reason) = skip_reason {
2503                        replay_results.push(serde_json::json!({
2504                            "command": call.command,
2505                            "status": "skipped",
2506                            "reason": reason,
2507                        }));
2508                        continue;
2509                    }
2510                    // Enforce the same command allow/blocklist as invoke_command
2511                    // (audit #30/#31): a recorded/imported session must not be able to
2512                    // invoke a command an operator blocked.
2513                    if !self.state.privacy.is_invoke_allowed(&call.command)
2514                        || !self.state.privacy.is_command_allowed(&call.command)
2515                    {
2516                        replay_results.push(serde_json::json!({
2517                            "command": call.command,
2518                            "status": "blocked",
2519                            "error": "blocked by privacy configuration",
2520                        }));
2521                        continue;
2522                    }
2523                    let code = format!(
2524                        "return window.__TAURI_INTERNALS__.invoke({})",
2525                        js_string(&call.command)
2526                    );
2527                    // Replay each call in the window that made it — never a default window: a
2528                    // command recorded in a low-privilege window must not run with main's
2529                    // capabilities. A window that no longer exists fails the call (an explicit
2530                    // label never falls back to another window).
2531                    let outcome = match self
2532                        .eval_with_return(&code, Some(call.webview_label.as_str()))
2533                        .await
2534                    {
2535                        Ok(result_str) => {
2536                            let value: serde_json::Value = serde_json::from_str(&result_str)
2537                                .unwrap_or(serde_json::Value::String(result_str));
2538                            let shape = crate::introspection::JsonShape::from_value(&value);
2539                            serde_json::json!({
2540                                "command": call.command,
2541                                "webview_label": call.webview_label,
2542                                "status": "ok",
2543                                "response_type": shape.type_name(),
2544                            })
2545                        }
2546                        Err(e) => {
2547                            serde_json::json!({
2548                                "command": call.command,
2549                                "status": "error",
2550                                "error": e,
2551                            })
2552                        }
2553                    };
2554                    replay_results.push(outcome);
2555                }
2556                let count = |status: &str| {
2557                    replay_results
2558                        .iter()
2559                        .filter(|r| r.get("status").and_then(|s| s.as_str()) == Some(status))
2560                        .count()
2561                };
2562                let (passed, skipped) = (count("ok"), count("skipped"));
2563                let replayed = replay_results.len() - skipped;
2564                let result = serde_json::json!({
2565                    "replayed": replayed,
2566                    "passed": passed,
2567                    "failed": replayed - passed,
2568                    "skipped": skipped,
2569                    "note": "commands are re-invoked WITHOUT arguments (recordings do not capture \
2570                             them), each in the window that recorded it, and their side effects \
2571                             happen again; calls that had arguments, failed, never completed, \
2572                             were answered by a route rule, or were captured on Tauri's \
2573                             postMessage IPC path are skipped",
2574                    "results": replay_results,
2575                });
2576                json_result(&result)
2577            }
2578        }
2579    }
2580
2581    #[tool(
2582        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).",
2583        annotations(
2584            read_only_hint = false,
2585            destructive_hint = false,
2586            idempotent_hint = true,
2587            open_world_hint = false
2588        )
2589    )]
2590    async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
2591        match params.action {
2592            InspectAction::GetStyles => {
2593                let Some(ref_id) = &params.ref_id else {
2594                    return missing_param("ref_id", "get_styles");
2595                };
2596                let props_arg = match &params.properties {
2597                    Some(props) => {
2598                        let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
2599                        format!("[{}]", arr.join(","))
2600                    }
2601                    None => "null".to_string(),
2602                };
2603                let code = format!(
2604                    "return window.__VICTAURI__?.getStyles({}, {})",
2605                    js_string(ref_id),
2606                    props_arg
2607                );
2608                self.eval_page_action(&code, params.webview_label.as_deref())
2609                    .await
2610            }
2611            InspectAction::GetBoundingBoxes => {
2612                let Some(ref_ids) = &params.ref_ids else {
2613                    return missing_param("ref_ids", "get_bounding_boxes");
2614                };
2615                let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
2616                let code = format!(
2617                    "return window.__VICTAURI__?.getBoundingBoxes([{}])",
2618                    refs.join(",")
2619                );
2620                self.eval_page_action(&code, params.webview_label.as_deref())
2621                    .await
2622            }
2623            InspectAction::Highlight => {
2624                // highlight injects a debug overlay node into the page — a DOM
2625                // mutation — so it is gated separately and excluded from the
2626                // read-only Observe profile (red-team P1).
2627                if !self.state.privacy.is_tool_enabled("inspect.highlight") {
2628                    return tool_disabled("inspect.highlight");
2629                }
2630                let Some(ref_id) = &params.ref_id else {
2631                    return missing_param("ref_id", "highlight");
2632                };
2633                let color_arg = match &params.color {
2634                    Some(c) => match sanitize_css_color(c) {
2635                        Ok(safe) => format!("\"{safe}\""),
2636                        Err(e) => return tool_error(e),
2637                    },
2638                    None => "null".to_string(),
2639                };
2640                let label_arg = match &params.label {
2641                    Some(l) => js_string(l),
2642                    None => "null".to_string(),
2643                };
2644                let code = format!(
2645                    "return window.__VICTAURI__?.highlightElement({}, {}, {})",
2646                    js_string(ref_id),
2647                    color_arg,
2648                    label_arg
2649                );
2650                self.eval_page_action(&code, params.webview_label.as_deref())
2651                    .await
2652            }
2653            InspectAction::ClearHighlights => {
2654                if !self
2655                    .state
2656                    .privacy
2657                    .is_tool_enabled("inspect.clear_highlights")
2658                {
2659                    return tool_disabled("inspect.clear_highlights");
2660                }
2661                self.eval_page_action(
2662                    "return window.__VICTAURI__?.clearHighlights()",
2663                    params.webview_label.as_deref(),
2664                )
2665                .await
2666            }
2667            InspectAction::AuditAccessibility => {
2668                self.eval_page_action(
2669                    "return window.__VICTAURI__?.auditAccessibility()",
2670                    params.webview_label.as_deref(),
2671                )
2672                .await
2673            }
2674            InspectAction::GetPerformance => {
2675                self.eval_page_action(
2676                    "return window.__VICTAURI__?.getPerformanceMetrics()",
2677                    params.webview_label.as_deref(),
2678                )
2679                .await
2680            }
2681        }
2682    }
2683
2684    #[tool(
2685        description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
2686        annotations(
2687            read_only_hint = false,
2688            destructive_hint = false,
2689            idempotent_hint = true,
2690            open_world_hint = false
2691        )
2692    )]
2693    async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
2694        match params.action {
2695            CssAction::Inject => {
2696                if !self.state.privacy.is_tool_enabled("inject_css") {
2697                    return tool_disabled("inject_css");
2698                }
2699                let Some(css) = &params.css else {
2700                    return missing_param("css", "inject");
2701                };
2702                // Block remote @import / url(...) exfil vectors unless explicitly opted in.
2703                if let Err(e) = sanitize_injected_css(css, params.allow_remote) {
2704                    return tool_error(e);
2705                }
2706                let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
2707                self.eval_bridge(&code, params.webview_label.as_deref())
2708                    .await
2709            }
2710            CssAction::Remove => {
2711                if !self.state.privacy.is_tool_enabled("css.remove") {
2712                    return tool_disabled("css.remove");
2713                }
2714                self.eval_bridge(
2715                    "return window.__VICTAURI__?.removeInjectedCss()",
2716                    params.webview_label.as_deref(),
2717                )
2718                .await
2719            }
2720        }
2721    }
2722
2723    #[tool(
2724        description = "Network request interception (Playwright route() equivalent, no CDP). \
2725            Matches webview fetch/XHR by URL and blocks, mocks, or delays them. \
2726            Actions:\n\
2727            - `add`: add a rule. `pattern` (+ optional `match_type`: substring/glob/regex/exact, \
2728              and `method`) selects requests; `behavior` is `block` (abort), `fulfill` (return a \
2729              mock `status`/`headers`/`body`/`content_type`), or `delay` (proceed after `delay_ms`). \
2730              `times` limits how often it fires. Rules are page-scoped (cleared on reload).\n\
2731            - `list`: list active rules.\n\
2732            - `clear` (by `id`) / `clear_all`: remove rules.\n\
2733            - `matches`: log of intercepted requests.\n\
2734            Note: fetch supports all behaviors; XHR supports block/delay (fulfill is fetch-only). \
2735            Top-level navigation, sub-resource (img/css), and WebSocket traffic are not intercepted. \
2736            Tauri IPC: on the fetch transport (desktop, until a fallback) a rule matching an IPC URL \
2737            (http(s)://ipc.localhost/<cmd> or ipc://localhost/<cmd>) DOES change what the app's real \
2738            `invoke` gets. `fulfill` mocks the command (the Rust handler never runs; invoke resolves \
2739            only with a `Tauri-Response: ok` header, else rejects with the body); `delay` holds the \
2740            call back; `block` does NOT fail it (Tauri retries over postMessage and stays there until \
2741            reload). Mocked calls are tagged `mocked` in the IPC log and kept out of ghost detection, \
2742            command_catalog, coverage and timings; `add` returns an `ipc_note` when a rule can match \
2743            IPC. postMessage IPC (Android, after a fallback) is not affected. The `fault` tool \
2744            instead acts on commands you drive via `invoke_command`.",
2745        annotations(
2746            read_only_hint = false,
2747            destructive_hint = false,
2748            idempotent_hint = false,
2749            open_world_hint = false
2750        )
2751    )]
2752    async fn route(&self, Parameters(params): Parameters<RouteParams>) -> CallToolResult {
2753        match params.action {
2754            RouteAction::Add => {
2755                if !self.state.privacy.is_tool_enabled("route.add") {
2756                    return tool_disabled("route.add");
2757                }
2758                let Some(pattern) = &params.pattern else {
2759                    return missing_param("pattern", "add");
2760                };
2761                let behavior = params.behavior.unwrap_or(RouteBehavior::Fulfill);
2762                let match_type = params.match_type.unwrap_or(RouteMatchType::Substring);
2763                let mut rule = serde_json::json!({
2764                    "pattern": pattern,
2765                    "match_type": match_type.as_str(),
2766                    "action": behavior.as_str(),
2767                });
2768                if let Some(m) = &params.method {
2769                    rule["method"] = serde_json::json!(m);
2770                }
2771                if let Some(s) = params.status {
2772                    rule["status"] = serde_json::json!(s);
2773                }
2774                if let Some(st) = &params.status_text {
2775                    rule["status_text"] = serde_json::json!(st);
2776                }
2777                if let Some(h) = &params.headers {
2778                    rule["headers"] = h.clone();
2779                }
2780                if let Some(b) = &params.body {
2781                    // A JSON string body is passed through as-is; structured JSON
2782                    // is stringified so the bridge sends valid JSON text.
2783                    rule["body"] = match b {
2784                        serde_json::Value::String(s) => serde_json::json!(s),
2785                        other => serde_json::json!(other.to_string()),
2786                    };
2787                }
2788                if let Some(ct) = &params.content_type {
2789                    rule["content_type"] = serde_json::json!(ct);
2790                }
2791                if let Some(d) = params.delay_ms {
2792                    // A delayed request is held this long in the page; cap it like a `fault`
2793                    // delay rather than accept any u64.
2794                    if d > MAX_FAULT_DELAY_MS {
2795                        return tool_error_with_hint(
2796                            format!("delay_ms {d} exceeds the maximum of {MAX_FAULT_DELAY_MS} ms"),
2797                            RecoveryHint::CheckInput,
2798                        );
2799                    }
2800                    rule["delay_ms"] = serde_json::json!(d);
2801                }
2802                if let Some(t) = params.times {
2803                    rule["times"] = serde_json::json!(t);
2804                }
2805                let code = format!(
2806                    "return window.__VICTAURI__?.addRoute({})",
2807                    js_string(&rule.to_string())
2808                );
2809                self.eval_page_action(&code, params.webview_label.as_deref())
2810                    .await
2811            }
2812            RouteAction::List => {
2813                self.eval_bridge(
2814                    "return window.__VICTAURI__?.getRouteRules()",
2815                    params.webview_label.as_deref(),
2816                )
2817                .await
2818            }
2819            RouteAction::Clear => {
2820                let Some(id) = params.id else {
2821                    return missing_param("id", "clear");
2822                };
2823                let code = format!(
2824                    "return {}?.clearRoute({id})",
2825                    crate::js_bridge::agent_ops_js()
2826                );
2827                self.eval_bridge(&code, params.webview_label.as_deref())
2828                    .await
2829            }
2830            RouteAction::ClearAll => {
2831                let code = format!("return {}?.clearRoutes()", crate::js_bridge::agent_ops_js());
2832                self.eval_bridge(&code, params.webview_label.as_deref())
2833                    .await
2834            }
2835            RouteAction::Matches => {
2836                let limit = params.limit.unwrap_or(100);
2837                // A maximum of 0 entries is none — not "all" (the bridge's falsy limit).
2838                if limit == 0 {
2839                    return CallToolResult::success(vec![ContentBlock::text("[]")]);
2840                }
2841                let code = format!("return window.__VICTAURI__?.getRouteMatches({limit})");
2842                self.eval_bridge(&code, params.webview_label.as_deref())
2843                    .await
2844            }
2845        }
2846    }
2847
2848    #[tool(
2849        description = "Screencast / visual trace (no CDP). Captures the window at a fixed interval \
2850            into a ring buffer, forming a visual timeline that pairs with `recording` (events) and \
2851            `logs` (network/console). Actions:\n\
2852            - `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set \
2853              `with_events=true` to also start the event recorder. Hidden windows are skipped \
2854              (no frame), the buffer is capped at 256 MB, and an abandoned trace auto-stops \
2855              after 30 minutes.\n\
2856            - `stop`: stop and return a summary (frame count, duration, timestamps); also stops \
2857              the recording that `with_events` started (never one started separately).\n\
2858            - `status`: active flag + buffered frame count.\n\
2859            - `frames`: return captured frames as base64 PNGs, newest first up to a 25 MB \
2860              response (`limit` caps how many).",
2861        annotations(
2862            read_only_hint = false,
2863            destructive_hint = false,
2864            idempotent_hint = false,
2865            open_world_hint = false
2866        )
2867    )]
2868    async fn trace(&self, Parameters(params): Parameters<TraceParams>) -> CallToolResult {
2869        if !self.state.privacy.is_tool_enabled("trace")
2870            || !self.state.privacy.is_tool_enabled("screenshot")
2871        {
2872            return tool_disabled("trace");
2873        }
2874        match params.action {
2875            TraceAction::Start => {
2876                let interval = params.interval_ms.unwrap_or(500);
2877                let max_frames = params.max_frames.unwrap_or(60);
2878                let label = params.webview_label.clone();
2879                // A trace started over a running one supersedes it: end the recording the
2880                // previous trace started (if it is still the active one), or it would be left
2881                // running with no owner (and the per-second drain loop with it).
2882                let (generation, superseded) =
2883                    self.state
2884                        .screencast
2885                        .start(interval, max_frames, label.clone());
2886                if let Some(prev) = superseded {
2887                    let _ = self.state.recorder.stop_if_generation(prev);
2888                }
2889
2890                let mut events_started = false;
2891                if params.with_events.unwrap_or(false) {
2892                    let session_id = uuid::Uuid::new_v4().to_string();
2893                    let floor_ms = now_ms();
2894                    if let Ok(recording) = self.state.recorder.start_session(session_id) {
2895                        self.state.drain_watermarks.reset(floor_ms, recording);
2896                        // Only a recording THIS trace started is stopped with it — by recorder
2897                        // generation, so a recording started later (even under the same session
2898                        // id) is never stopped by us. If this trace was already stopped or
2899                        // superseded meanwhile, nothing would ever stop the recording: do it now.
2900                        if self
2901                            .state
2902                            .screencast
2903                            .set_owned_recording(generation, recording)
2904                        {
2905                            events_started = true;
2906                        } else {
2907                            let _ = self.state.recorder.stop_if_generation(recording);
2908                        }
2909                    }
2910                }
2911
2912                // Background capture task: snapshot the window each interval until the
2913                // screencast is stopped, superseded by a newer start, or hits the max duration.
2914                // The guard ends the trace (and its recording) however the task exits — the
2915                // max-duration break, or a panic.
2916                let handler = self.clone();
2917                let screencast = self.state.screencast.clone();
2918                let guard = crate::screencast::CaptureTaskGuard {
2919                    screencast: Arc::clone(&screencast),
2920                    recorder: self.state.recorder.clone(),
2921                    generation,
2922                };
2923                tokio::spawn(async move {
2924                    let _guard = guard;
2925                    let t0 = std::time::Instant::now();
2926                    // An abandoned trace must not capture (and burn CPU) forever: past the max
2927                    // duration the loop ends and the guard stops it (only if no newer trace has
2928                    // started in the meantime).
2929                    while screencast.is_current(generation) && t0.elapsed() < MAX_TRACE_DURATION {
2930                        // Same visible-target rule as `screenshot`: a hidden window yields
2931                        // stale or another window's pixels, so skip the frame instead.
2932                        if let Ok(target) = handler.resolve_visible_capture_target(label.as_deref())
2933                            && let Ok(handle) = handler.bridge.get_native_handle(Some(&target))
2934                            && let Ok(png) = crate::screenshot::capture_window(handle).await
2935                        {
2936                            use base64::Engine;
2937                            let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2938                            #[allow(clippy::cast_possible_truncation)]
2939                            screencast.push_frame_if_current(
2940                                generation,
2941                                t0.elapsed().as_millis() as u64,
2942                                b64,
2943                            );
2944                        }
2945                        tokio::time::sleep(std::time::Duration::from_millis(
2946                            screencast.interval_ms(),
2947                        ))
2948                        .await;
2949                    }
2950                });
2951
2952                json_result(&serde_json::json!({
2953                    "started": true,
2954                    "interval_ms": self.state.screencast.interval_ms(),
2955                    "max_frames": max_frames.clamp(1, 600),
2956                    "with_events": events_started,
2957                }))
2958            }
2959            TraceAction::Stop => {
2960                let (frame_count, owned) = self.state.screencast.stop();
2961                let timestamps = self.state.screencast.frame_timestamps();
2962                let duration_ms = timestamps.last().copied().unwrap_or(0);
2963                // Stop the recording `with_events` started, so the recorder (and the
2964                // per-second drain loop it enables) does not outlive the trace. The session
2965                // stays readable via recording get_events/export (last stopped session).
2966                let event_count = match owned {
2967                    Some(owned) => self
2968                        .state
2969                        .recorder
2970                        .stop_if_generation(owned)
2971                        .map_or(0, |session| session.events.len()),
2972                    None => self.state.recorder.event_count(),
2973                };
2974                json_result(&serde_json::json!({
2975                    "stopped": true,
2976                    "frame_count": frame_count,
2977                    "duration_ms": duration_ms,
2978                    "frame_timestamps_ms": timestamps,
2979                    "recorded_event_count": event_count,
2980                    "hint": "use action=frames to retrieve PNGs; pair with recording/get_events and logs for a full bundle",
2981                }))
2982            }
2983            TraceAction::Status => json_result(&serde_json::json!({
2984                "active": self.state.screencast.is_active(),
2985                "frame_count": self.state.screencast.frame_count(),
2986                "interval_ms": self.state.screencast.interval_ms(),
2987            })),
2988            TraceAction::Frames => {
2989                let limit = params.limit.unwrap_or(0);
2990                let frames = self.state.screencast.frames(limit);
2991                if frames.is_empty() {
2992                    return json_result(&serde_json::json!({ "frames": 0 }));
2993                }
2994                // Bound the response: keep the NEWEST frames that fit the byte budget.
2995                let available = frames.len();
2996                let mut budget = MAX_TRACE_FRAMES_RESPONSE_BYTES;
2997                let mut kept: Vec<ContentBlock> = Vec::new();
2998                for f in frames.into_iter().rev() {
2999                    if f.data_b64.len() > budget && !kept.is_empty() {
3000                        break;
3001                    }
3002                    budget = budget.saturating_sub(f.data_b64.len());
3003                    kept.push(ContentBlock::image(f.data_b64, "image/png"));
3004                }
3005                kept.reverse();
3006                if kept.len() < available {
3007                    kept.insert(
3008                        0,
3009                        ContentBlock::text(format!(
3010                            "returned the newest {} of {available} frames (response capped at \
3011                             {} MB); pass a smaller `limit` to page",
3012                            kept.len(),
3013                            MAX_TRACE_FRAMES_RESPONSE_BYTES / (1024 * 1024)
3014                        )),
3015                    );
3016                }
3017                CallToolResult::success(kept)
3018            }
3019        }
3020    }
3021
3022    #[tool(
3023        description = "Animation introspection (no CDP). Reads the Web Animations API to reveal what \
3024            the webview's animation engine is actually running — duration, delay, easing, iterations, \
3025            keyframes, current progress, and the animating element. Standard DOM, so it works \
3026            identically on WebView2/WKWebView/WebKitGTK. Actions:\n\
3027            - `list`: return all running CSS animations/transitions (optionally scoped by `selector`), \
3028              each with declared `timing`, `computed` progress, `keyframes`, and `target`.\n\
3029            - `scrub`: deterministically pause the target's animation and seek it to `points` \
3030              evenly-spaced steps (default 20), returning the exact geometry curve (rect + transform \
3031              + opacity per step). With `capture=true`, also returns a single contact-sheet filmstrip \
3032              PNG (one image of the whole arc) plus a `manifest` mapping each cell to its progress/time. \
3033              Frozen frames are jank-free, so this beats real-time capture for fast sweeps. CSS-driven \
3034              animations only (JS/rAF animations are not seekable — use `list`/`sample`).\n\
3035            - `sample`: real-time motion recorder. `record=true` arms a requestAnimationFrame watcher \
3036              on `selector` (or the first animating element); then trigger the animation; then call \
3037              with `record=false` to read the measured per-frame curve plus jank stats (dropped frames, \
3038              max frame gap) and declared-vs-measured duration. Works for ANY animation including \
3039              JS/rAF-driven ones. `clear=true` resets recorded sessions.\n\
3040            NOTE: an animation only appears while it is running or pending — trigger it (e.g. show the \
3041            notification) just before calling `list`/`scrub`, or arm `sample` before triggering.",
3042        annotations(
3043            read_only_hint = false,
3044            destructive_hint = false,
3045            idempotent_hint = false,
3046            open_world_hint = false
3047        )
3048    )]
3049    async fn animation(&self, Parameters(params): Parameters<AnimationParams>) -> CallToolResult {
3050        if !self.state.privacy.is_tool_enabled("animation") {
3051            return tool_disabled("animation");
3052        }
3053        match params.action {
3054            AnimationAction::List => {
3055                let sel = params
3056                    .selector
3057                    .as_deref()
3058                    .map_or_else(|| "null".to_string(), js_string);
3059                let code = format!(
3060                    "return window.__VICTAURI__ && window.__VICTAURI__.listAnimations({sel})"
3061                );
3062                match self
3063                    .eval_with_return(&code, params.webview_label.as_deref())
3064                    .await
3065                {
3066                    Ok(result_str) => {
3067                        match serde_json::from_str::<serde_json::Value>(&result_str) {
3068                            Ok(v) => json_result(&v),
3069                            Err(_) => CallToolResult::success(vec![ContentBlock::text(result_str)]),
3070                        }
3071                    }
3072                    Err(e) => tool_error(format!("animation list failed: {e}")),
3073                }
3074            }
3075            AnimationAction::Scrub => {
3076                // `capture=true` takes native window screenshots (a filmstrip), so it needs the
3077                // `screenshot` tool as well — like `trace` — or an operator who disabled
3078                // screenshots would still get pixels through here. Refused before anything runs.
3079                if params.capture.unwrap_or(false)
3080                    && !self.state.privacy.is_tool_enabled("screenshot")
3081                {
3082                    return tool_error_with_hint(
3083                        "animation scrub with capture=true takes native window screenshots, but \
3084                         tool 'screenshot' is disabled by privacy configuration — call scrub \
3085                         without `capture` to get the geometry curve only",
3086                        RecoveryHint::ReportToUser,
3087                    );
3088                }
3089                self.animation_scrub(params).await
3090            }
3091            AnimationAction::Sample => {
3092                let label = params.webview_label.as_deref();
3093                let sel = params
3094                    .selector
3095                    .as_deref()
3096                    .map_or_else(|| "null".to_string(), js_string);
3097                let code = if params.record.unwrap_or(false) {
3098                    crate::js_bridge::agent_op_call_js(&format!("installSweepRecorder({sel})"))
3099                } else {
3100                    let clear = params.clear.unwrap_or(false);
3101                    crate::js_bridge::agent_op_call_js(&format!("readSweep({clear})"))
3102                };
3103                match self.eval_with_return(&code, label).await {
3104                    Ok(result_str) => {
3105                        match serde_json::from_str::<serde_json::Value>(&result_str) {
3106                            Ok(v) => json_result(&v),
3107                            Err(_) => CallToolResult::success(vec![ContentBlock::text(result_str)]),
3108                        }
3109                    }
3110                    Err(e) => tool_error(format!("animation sample failed: {e}")),
3111                }
3112            }
3113        }
3114    }
3115
3116    /// Deterministic pause-seek-capture loop for `animation scrub`. Split out to
3117    /// keep the `#[tool]` method readable.
3118    async fn animation_scrub(&self, params: AnimationParams) -> CallToolResult {
3119        let label = params.webview_label.as_deref();
3120        let sel = params
3121            .selector
3122            .as_deref()
3123            .map_or_else(|| "null".to_string(), js_string);
3124
3125        // 1. Prepare: pause the target's animations, learn the timeline length.
3126        let prep_code = crate::js_bridge::agent_op_call_js(&format!("scrubPrepare({sel})"));
3127        let prep_v = match self.eval_with_return(&prep_code, label).await {
3128            Ok(s) => {
3129                serde_json::from_str::<serde_json::Value>(&s).unwrap_or(serde_json::Value::Null)
3130            }
3131            Err(e) => return tool_error(format!("scrub prepare failed: {e}")),
3132        };
3133        if prep_v.get("prepared").and_then(serde_json::Value::as_bool) != Some(true) {
3134            // Surface the helpful error/info object (no target, JS-driven, etc.).
3135            return json_result(&prep_v);
3136        }
3137
3138        let points = params.points.unwrap_or(20).clamp(2, 120);
3139        let capture = params.capture.unwrap_or(false);
3140        let cols_for = |n: usize| {
3141            params
3142                .cols
3143                .unwrap_or_else(|| crate::filmstrip::default_cols(n))
3144        };
3145        let resume = params.restore.unwrap_or(true);
3146        let restore_code = crate::js_bridge::agent_op_call_js(&format!("scrubRestore({resume})"));
3147        let mut curve: Vec<serde_json::Value> = Vec::with_capacity(points);
3148        let mut frames: Vec<crate::filmstrip::Frame> = Vec::new();
3149        let mut manifest: Vec<serde_json::Value> = Vec::new();
3150        // The first reason a frame could not be captured (reported, never swallowed).
3151        let mut capture_error: Option<String> = None;
3152
3153        // 2. Seek to each evenly-spaced point; capture the frozen frame if asked.
3154        for i in 0..points {
3155            #[allow(clippy::cast_precision_loss)]
3156            let progress = i as f64 / (points - 1) as f64;
3157            let seek_code = crate::js_bridge::agent_op_call_js(&format!("scrubSeek({progress})"));
3158            match self.eval_with_return(&seek_code, label).await {
3159                Ok(s) => {
3160                    let v = serde_json::from_str::<serde_json::Value>(&s)
3161                        .unwrap_or(serde_json::Value::Null);
3162                    if capture {
3163                        match self.capture_scrub_frame(label).await {
3164                            Ok(frame) => {
3165                                // Before holding more frames: would the finished sheet be
3166                                // composable at all? (Raw RGBA frames accumulate until then.)
3167                                if frames.is_empty()
3168                                    && let Err(e) = crate::filmstrip::check_sheet(
3169                                        frame.w,
3170                                        frame.h,
3171                                        points,
3172                                        cols_for(points),
3173                                        FILMSTRIP_GAP,
3174                                    )
3175                                {
3176                                    let _ = self.eval_with_return(&restore_code, label).await;
3177                                    let fitting = (2..points).rev().find(|&n| {
3178                                        crate::filmstrip::check_sheet(
3179                                            frame.w,
3180                                            frame.h,
3181                                            n,
3182                                            cols_for(n),
3183                                            FILMSTRIP_GAP,
3184                                        )
3185                                        .is_ok()
3186                                    });
3187                                    let advice = fitting.map_or_else(
3188                                        || "call scrub without `capture` for the geometry curve                                             (or shrink the window)"
3189                                            .to_string(),
3190                                        |n| format!("use `points` <= {n} (or more `cols`)"),
3191                                    );
3192                                    return tool_error(format!(
3193                                        "animation scrub capture refused before capturing: {e};                                          {advice}"
3194                                    ));
3195                                }
3196                                manifest.push(serde_json::json!({
3197                                    "cell": frames.len(),
3198                                    "progress": progress,
3199                                    "t": v.get("t").cloned().unwrap_or(serde_json::Value::Null),
3200                                }));
3201                                frames.push(frame);
3202                            }
3203                            Err(e) => {
3204                                capture_error.get_or_insert(e);
3205                            }
3206                        }
3207                    }
3208                    curve.push(v);
3209                }
3210                Err(e) => curve.push(serde_json::json!({ "progress": progress, "error": e })),
3211            }
3212        }
3213
3214        // 3. Restore (resume) or leave paused.
3215        let _ = self.eval_with_return(&restore_code, label).await;
3216
3217        let mut meta = serde_json::json!({
3218            "scrubbed": true,
3219            "points": points,
3220            "duration_ms": prep_v.get("duration").cloned().unwrap_or(serde_json::Value::Null),
3221            "anim_count": prep_v.get("anim_count").cloned().unwrap_or(serde_json::Value::Null),
3222            "target": prep_v.get("target").cloned().unwrap_or(serde_json::Value::Null),
3223            // True only when a filmstrip is returned with this result.
3224            "captured": false,
3225            "curve": curve,
3226        });
3227        if let Some(e) = &capture_error {
3228            meta["capture_error"] = serde_json::json!(e);
3229        }
3230
3231        // 4. Compose the filmstrip if we captured frames.
3232        if capture && !frames.is_empty() {
3233            let cols = cols_for(frames.len());
3234            // Composing and encoding a sheet of up to hundreds of MB is seconds of CPU: off the
3235            // server's (two) async workers, or `/health` and every other call stall behind it.
3236            let composed = tokio::task::spawn_blocking(move || -> Result<_, String> {
3237                use base64::Engine;
3238                let (rgba, w, h) =
3239                    crate::filmstrip::compose(&frames, cols, FILMSTRIP_GAP, [20, 20, 20, 255])
3240                        .map_err(|e| format!("filmstrip compose failed: {e}"))?;
3241                drop(frames);
3242                let png = crate::screenshot::encode_png(w, h, &rgba)
3243                    .map_err(|e| format!("filmstrip encode failed: {e}"))?;
3244                Ok((base64::engine::general_purpose::STANDARD.encode(&png), w, h))
3245            })
3246            .await
3247            .unwrap_or_else(|e| Err(format!("filmstrip composition failed: {e}")));
3248            match composed {
3249                Ok((b64, w, h)) => {
3250                    meta["captured"] = serde_json::json!(true);
3251                    meta["filmstrip"] = serde_json::json!({
3252                        "cols": cols,
3253                        "frame_count": manifest.len(),
3254                        "width": w,
3255                        "height": h,
3256                        "manifest": manifest,
3257                    });
3258                    return CallToolResult::success(vec![
3259                        ContentBlock::image(b64, "image/png"),
3260                        ContentBlock::text(meta.to_string()),
3261                    ]);
3262                }
3263                Err(e) => return tool_error(e),
3264            }
3265        }
3266
3267        json_result(&meta)
3268    }
3269
3270    /// One native capture of the (frozen) scrub target window, as a filmstrip frame.
3271    async fn capture_scrub_frame(
3272        &self,
3273        label: Option<&str>,
3274    ) -> Result<crate::filmstrip::Frame, String> {
3275        let handle = self
3276            .bridge
3277            .get_native_handle(label)
3278            .map_err(|e| format!("no native window handle: {e}"))?;
3279        let (rgba, w, h) = crate::screenshot::capture_window_raw(handle)
3280            .await
3281            .map_err(|e| format!("window capture failed: {e}"))?;
3282        crate::filmstrip::Frame::new(rgba, w, h)
3283            .ok_or_else(|| format!("window capture returned a malformed {w}x{h} frame"))
3284    }
3285
3286    #[tool(
3287        description = "Application logs and monitoring. Actions: console (captured console.log/warn/error), network (intercepted fetch/XHR), ipc (IPC call log — set wait_for_capture=true to await response capture up to 500ms; entries captured on Tauri's postMessage IPC fallback carry transport:'post_message', see get_diagnostics ipc_capture), navigation (URL change history), dialogs (alert/confirm/prompt events), events (combined event stream), slow_ipc (find slow IPC calls), clear (DELETES the captured IPC + network logs — use for per-test isolation). BACKEND (the Rust side — what the app prints to its debug console): backend (structured Rust log entries — tracing/log records with typed fields + span context, plus captured panics; filter by level / target prefix / filter text / `fields` (structured-field equality), page with since_seq→next_seq), backend_digest (START HERE: counts per level, noisiest targets, repeated messages collapsed into templates, recent warnings/errors, every panic — one cheap call instead of paging thousands of lines), stdout (raw stdout/stderr lines when the app was launched via `victauri run` — also shows how the process exited).",
3288        annotations(
3289            read_only_hint = false,
3290            destructive_hint = true,
3291            idempotent_hint = true,
3292            open_world_hint = false
3293        )
3294    )]
3295    async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
3296        // `limit` is the maximum number of entries to return, so 0 returns none (R5-JS5). The
3297        // page is not asked at all: `.slice(-0)` and the bridge's "falsy limit = everything"
3298        // used to turn it into EVERY entry, bodies included.
3299        if params.limit == Some(0)
3300            && matches!(
3301                params.action,
3302                LogsAction::Console
3303                    | LogsAction::Network
3304                    | LogsAction::Ipc
3305                    | LogsAction::Navigation
3306                    | LogsAction::Dialogs
3307                    | LogsAction::Events
3308            )
3309        {
3310            return CallToolResult::success(vec![ContentBlock::text("[]")]);
3311        }
3312        match params.action {
3313            LogsAction::Console => {
3314                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
3315                let base = if since_arg.is_empty() {
3316                    "window.__VICTAURI__?.getConsoleLogs()".to_string()
3317                } else {
3318                    format!("window.__VICTAURI__?.getConsoleLogs({since_arg})")
3319                };
3320                let code = if let Some(limit) = params.limit {
3321                    format!("return ({base} || []).slice(-{limit})")
3322                } else {
3323                    format!("return {base}")
3324                };
3325                self.eval_bridge(&code, params.webview_label.as_deref())
3326                    .await
3327            }
3328            LogsAction::Network => {
3329                let filter_arg = params
3330                    .filter
3331                    .as_ref()
3332                    .map_or_else(|| "null".to_string(), |f| js_string(f));
3333                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
3334                let source = format!("window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit})");
3335                let code = trimmed_log_js(&source, limit);
3336                self.eval_bridge(&code, params.webview_label.as_deref())
3337                    .await
3338            }
3339            LogsAction::Ipc => {
3340                let wait = params.wait_for_capture.unwrap_or(false);
3341                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
3342                if wait {
3343                    let inner =
3344                        trimmed_log_js(&format!("window.__VICTAURI__.getIpcLog({limit})"), limit);
3345                    let code = format!(
3346                        r"return (async function() {{
3347                            await window.__VICTAURI__.waitForIpcComplete(500);
3348                            return (function() {{ {inner} }})();
3349                        }})()"
3350                    );
3351                    let timeout = std::time::Duration::from_millis(5000);
3352                    match self
3353                        .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
3354                        .await
3355                    {
3356                        Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
3357                        Err(e) => tool_error(e),
3358                    }
3359                } else {
3360                    let code =
3361                        trimmed_log_js(&format!("window.__VICTAURI__?.getIpcLog({limit})"), limit);
3362                    self.eval_bridge(&code, params.webview_label.as_deref())
3363                        .await
3364                }
3365            }
3366            LogsAction::Navigation => {
3367                let code = if let Some(limit) = params.limit {
3368                    format!(
3369                        "return (window.__VICTAURI__?.getNavigationLog() || []).slice(-{limit})"
3370                    )
3371                } else {
3372                    "return window.__VICTAURI__?.getNavigationLog()".to_string()
3373                };
3374                self.eval_bridge(&code, params.webview_label.as_deref())
3375                    .await
3376            }
3377            LogsAction::Dialogs => {
3378                let code = if let Some(limit) = params.limit {
3379                    format!("return (window.__VICTAURI__?.getDialogLog() || []).slice(-{limit})")
3380                } else {
3381                    "return window.__VICTAURI__?.getDialogLog()".to_string()
3382                };
3383                self.eval_bridge(&code, params.webview_label.as_deref())
3384                    .await
3385            }
3386            LogsAction::Events => {
3387                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
3388                let base = if since_arg.is_empty() {
3389                    "window.__VICTAURI__?.getEventStream()".to_string()
3390                } else {
3391                    format!("window.__VICTAURI__?.getEventStream({since_arg})")
3392                };
3393                let code = if let Some(limit) = params.limit {
3394                    format!("return ({base} || []).slice(-{limit})")
3395                } else {
3396                    format!("return {base}")
3397                };
3398                self.eval_bridge(&code, params.webview_label.as_deref())
3399                    .await
3400            }
3401            LogsAction::SlowIpc => {
3402                let Some(threshold) = params.threshold_ms else {
3403                    return missing_param("threshold_ms", "slow_ipc");
3404                };
3405                let limit = params.limit.unwrap_or(20);
3406                let code = slow_ipc_js(threshold, limit);
3407                self.eval_bridge(&code, params.webview_label.as_deref())
3408                    .await
3409            }
3410            LogsAction::Backend => self.logs_backend(&params),
3411            LogsAction::BackendDigest => self.logs_backend_digest(&params),
3412            LogsAction::Stdout => self.logs_stdout(&params),
3413            LogsAction::Clear => {
3414                // Clearing the IPC/network logs erases captured evidence — a
3415                // mutation of observable state — so it is gated separately and
3416                // excluded from the read-only Observe profile (red-team P1).
3417                if !self.state.privacy.is_tool_enabled("logs.clear") {
3418                    return tool_disabled("logs.clear");
3419                }
3420                let code = format!(
3421                    "return (function(){{ var b = {}; if (!b) return {{ ok:false, error:'bridge unavailable' }}; b.clearIpcLog(); b.clearNetworkLog(); return {{ ok:true, cleared:['ipc','network'] }}; }})()",
3422                    crate::js_bridge::agent_ops_js()
3423                );
3424                self.eval_bridge(&code, params.webview_label.as_deref())
3425                    .await
3426            }
3427        }
3428    }
3429
3430    // ── Backend Introspection ────────────────────────────────────────────────
3431
3432    #[tool(
3433        description = "Deep backend introspection — command profiling, IPC contract testing, \
3434            coverage, startup timing, capability auditing, database diagnostics, process \
3435            enumeration, and event bus monitoring. \
3436            These features exploit Victauri's position inside the Rust process.\n\n\
3437            Actions:\n\
3438            - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n\
3439            - `coverage`: Which registered commands have been called during this session.\n\
3440            - `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\
3441            - `contract_record`: Record a command's response shape as a baseline (requires `command`).\n\
3442            - `contract_check`: Check all recorded contracts for schema drift.\n\
3443            - `contract_list`: List all recorded contract baselines.\n\
3444            - `contract_clear`: Clear all recorded contract baselines.\n\
3445            - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
3446            - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
3447            - `db_health`: Read-only SQLite diagnostics: journal mode, page stats, freelist, \
3448              per-table row counts, and SQLite `quick_check` — each phase budgeted; a phase that \
3449              runs out is reported (`row_counts_complete`, `integrity_check: \"not completed…\"`) \
3450              instead of failing.\n\
3451            - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
3452            - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
3453            - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
3454            - `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\
3455            - `event_bus_clear`: Clear the event bus capture buffer.",
3456        annotations(
3457            read_only_hint = false,
3458            destructive_hint = true,
3459            idempotent_hint = false,
3460            open_world_hint = false
3461        )
3462    )]
3463    async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
3464        if !self.state.privacy.is_tool_enabled("introspect") {
3465            return tool_disabled("introspect");
3466        }
3467
3468        match params.action {
3469            IntrospectAction::CommandTimings => {
3470                let mut stats = self.state.command_timings.all_stats();
3471                let driven_count = stats.len();
3472                if let Some(threshold) = params.slow_threshold_ms {
3473                    stats.retain(|s| s.avg_ms >= threshold);
3474                }
3475
3476                // Real frontend traffic: derive per-command latency from the live IPC
3477                // log so the profiler is not blind to commands the app itself drives.
3478                // `command_timings` (above) only records Victauri-driven invoke_command
3479                // calls — on a running app that counter is typically 0 while the app
3480                // makes hundreds of real calls. The IPC log captures those with
3481                // duration; the name+duration projection stays under the eval cap.
3482                let code = ipc_timing_projection_js(None);
3483                let mut ipc_traffic = match self
3484                    .eval_with_return(&code, params.webview_label.as_deref())
3485                    .await
3486                {
3487                    Ok(json_str) => page_json::parse_page_json::<Vec<serde_json::Value>>(&json_str)
3488                        .map(|entries| ipc_timing_stats(&entries))
3489                        .unwrap_or_default(),
3490                    Err(_) => Vec::new(),
3491                };
3492                if let Some(threshold) = params.slow_threshold_ms {
3493                    ipc_traffic.retain(|s| {
3494                        s.get("avg_ms")
3495                            .and_then(serde_json::Value::as_f64)
3496                            .is_some_and(|a| a >= threshold)
3497                    });
3498                }
3499
3500                let ipc_capture = self
3501                    .ipc_capture_status(params.webview_label.as_deref())
3502                    .await;
3503                let result = serde_json::json!({
3504                    "commands": stats,
3505                    "total_commands_profiled": driven_count,
3506                    "ipc_capture": ipc_capture,
3507                    // The store stops tracking NEW command names at its cap; say so rather
3508                    // than let a missing command look like one that never ran.
3509                    "saturated": driven_count >= COMMAND_TIMINGS_CAP,
3510                    "ipc_traffic": ipc_traffic,
3511                    "ipc_commands_observed": ipc_traffic.len(),
3512                    "slow_threshold_ms": params.slow_threshold_ms,
3513                    "note": "`commands` profiles ONLY commands you drove through Victauri's \
3514                             invoke_command tool (often empty on a live app). `ipc_traffic` \
3515                             profiles the app's REAL frontend IPC, derived from the live IPC \
3516                             log (per-command call_count + min/max/avg/p95 latency) — that is \
3517                             the one reflecting actual usage.",
3518                });
3519                json_result(&result)
3520            }
3521            IntrospectAction::Coverage => {
3522                let registered: Vec<String> = self
3523                    .state
3524                    .registry
3525                    .list()
3526                    .iter()
3527                    .map(|c| c.name.clone())
3528                    .collect();
3529
3530                // Project to command NAMES ONLY. The previous full `getIpcLog()` carried
3531                // request/response bodies and blew the eval result cap on busy apps,
3532                // silently returning an empty set and reporting "0 invoked" despite live
3533                // traffic. This is the same name projection ghost detection uses.
3534                let code = ghost_ipc_projection_js(None);
3535                let (invoked, ipc_calls_observed): (std::collections::HashSet<String>, usize) =
3536                    match self
3537                        .eval_with_return(&code, params.webview_label.as_deref())
3538                        .await
3539                    {
3540                        Ok(json_str) => {
3541                            match page_json::parse_page_json::<Vec<String>>(&json_str) {
3542                                Ok(names) => {
3543                                    let count = names.len();
3544                                    (names.into_iter().collect(), count)
3545                                }
3546                                Err(_) => (std::collections::HashSet::new(), 0),
3547                            }
3548                        }
3549                        Err(_) => (std::collections::HashSet::new(), 0),
3550                    };
3551
3552                let uncovered: Vec<&String> = registered
3553                    .iter()
3554                    .filter(|cmd| !invoked.contains(cmd.as_str()))
3555                    .collect();
3556
3557                let coverage_pct = if registered.is_empty() {
3558                    100.0
3559                } else {
3560                    let covered = registered.len() - uncovered.len();
3561                    (covered as f64 / registered.len() as f64) * 100.0
3562                };
3563
3564                let note = if registered.is_empty() {
3565                    Some(
3566                        "The introspection registry is empty (the app does not use \
3567                         #[inspectable]/register_command_names), so coverage_pct is a \
3568                         placeholder 100%. `invoked_not_registered` still lists the real \
3569                         commands seen on the live IPC log — use it to inventory actual \
3570                         traffic.",
3571                    )
3572                } else if ipc_calls_observed == 0 {
3573                    Some(
3574                        "No IPC calls were observed on the live log. If the app is actively \
3575                         making calls, confirm the target webview and check `ipc_capture`: \
3576                         a `capture` other than `full` means the IPC picture is partial.",
3577                    )
3578                } else {
3579                    None
3580                };
3581                let ipc_capture = self
3582                    .ipc_capture_status(params.webview_label.as_deref())
3583                    .await;
3584
3585                let result = serde_json::json!({
3586                    "ipc_capture": ipc_capture,
3587                    "registered_commands": registered.len(),
3588                    "invoked_commands": invoked.len(),
3589                    "ipc_calls_observed": ipc_calls_observed,
3590                    "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
3591                    "uncovered": uncovered,
3592                    "invoked_not_registered": invoked.iter()
3593                        .filter(|cmd| !registered.contains(cmd))
3594                        .collect::<Vec<_>>(),
3595                    "note": note,
3596                });
3597                json_result(&result)
3598            }
3599            IntrospectAction::CommandCatalog => {
3600                // Mine the live IPC log for per-command argument + result SHAPES (inferred
3601                // in JS, bodies never shipped — so it stays under the eval cap on busy apps)
3602                // and merge with the #[inspectable] registry. This is the answer to a real
3603                // live gap: an app without #[inspectable] (e.g. 4DA — 379 commands, every
3604                // registry field null) gives an agent command NAMES but no call/return
3605                // schema; the IPC log holds the actual shapes, so we project them out.
3606                let code = ipc_catalog_projection_js();
3607                let ipc_entries: Vec<serde_json::Value> = match self
3608                    .eval_with_return(&code, params.webview_label.as_deref())
3609                    .await
3610                {
3611                    Ok(json_str) => page_json::parse_page_json(&json_str).unwrap_or_default(),
3612                    Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
3613                };
3614
3615                let registry = self.state.registry.list();
3616                let catalog = merge_command_catalog(&ipc_entries, &registry);
3617                let observed = catalog
3618                    .iter()
3619                    .filter(|c| {
3620                        c.get("observed")
3621                            .and_then(serde_json::Value::as_bool)
3622                            .unwrap_or(false)
3623                    })
3624                    .count();
3625                let ipc_capture = self
3626                    .ipc_capture_status(params.webview_label.as_deref())
3627                    .await;
3628
3629                let result = serde_json::json!({
3630                    "ipc_capture": ipc_capture,
3631                    "catalog": catalog,
3632                    "observed_count": observed,
3633                    "registered_count": registry.len(),
3634                    "total": catalog.len(),
3635                    "note": "`arg_shape`/`result_shape` are STRUCTURES inferred from the live \
3636                             IPC log (keys + value types, not values) — how to CALL each command \
3637                             and what it RETURNS, even for apps that don't use #[inspectable]. \
3638                             `observed:false` means the command is in the registry but hasn't \
3639                             been seen on the wire this session (drive the app's UI to populate \
3640                             its shape). `declared_*` fields, when present, come from \
3641                             #[inspectable] and are authoritative over the inferred shape.",
3642                });
3643                json_result(&result)
3644            }
3645            IntrospectAction::ContractRecord => {
3646                let Some(command) = params.command else {
3647                    return missing_param("command", "contract_record");
3648                };
3649                // contract_record invokes the command with caller-supplied args, so
3650                // it must honour the same allow/blocklist as invoke_command (audit #30).
3651                if !self.state.privacy.is_invoke_allowed(&command)
3652                    || !self.state.privacy.is_command_allowed(&command)
3653                {
3654                    return tool_error(format!(
3655                        "command '{command}' is blocked by privacy configuration"
3656                    ));
3657                }
3658                let args_json = params.args.unwrap_or(serde_json::json!({}));
3659                let args_str =
3660                    serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
3661                let code = format!(
3662                    "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
3663                    js_string(&command)
3664                );
3665                match self
3666                    .eval_with_return(&code, params.webview_label.as_deref())
3667                    .await
3668                {
3669                    Ok(result_str) => {
3670                        let value: serde_json::Value = serde_json::from_str(&result_str)
3671                            .unwrap_or(serde_json::Value::String(result_str.clone()));
3672                        let shape = crate::introspection::JsonShape::from_value(&value);
3673                        let sample = if result_str.len() > 4096 {
3674                            format!(
3675                                "{}...(truncated)",
3676                                truncate_at_char_boundary(&result_str, 4096)
3677                            )
3678                        } else {
3679                            result_str
3680                        };
3681                        let baseline = crate::introspection::ContractBaseline {
3682                            command: command.clone(),
3683                            args: args_json,
3684                            shape: shape.clone(),
3685                            sample,
3686                            recorded_at: chrono_now(),
3687                        };
3688                        self.state.contract_store.record(baseline);
3689                        let result = serde_json::json!({
3690                            "recorded": true,
3691                            "command": command,
3692                            "shape_type": shape.type_name(),
3693                        });
3694                        json_result(&result)
3695                    }
3696                    Err(e) => tool_error(format!(
3697                        "failed to invoke '{command}' for contract recording: {e}"
3698                    )),
3699                }
3700            }
3701            IntrospectAction::ContractCheck => {
3702                let baselines = self.state.contract_store.all();
3703                if baselines.is_empty() {
3704                    return json_result(&serde_json::json!({
3705                        "checked": 0,
3706                        "message": "no contract baselines recorded — use contract_record first",
3707                    }));
3708                }
3709                let mut results = Vec::new();
3710                for baseline in &baselines {
3711                    // Re-checking a baseline re-invokes the command; honour the
3712                    // allow/blocklist in case it changed since recording (audit #30).
3713                    if !self.state.privacy.is_invoke_allowed(&baseline.command)
3714                        || !self.state.privacy.is_command_allowed(&baseline.command)
3715                    {
3716                        continue;
3717                    }
3718                    let args_str =
3719                        serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
3720                    let code = format!(
3721                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
3722                        js_string(&baseline.command)
3723                    );
3724                    match self
3725                        .eval_with_return(&code, params.webview_label.as_deref())
3726                        .await
3727                    {
3728                        Ok(result_str) => {
3729                            let value: serde_json::Value = serde_json::from_str(&result_str)
3730                                .unwrap_or(serde_json::Value::String(result_str));
3731                            let current_shape = crate::introspection::JsonShape::from_value(&value);
3732                            let drift = crate::introspection::diff_shapes(
3733                                &baseline.shape,
3734                                &current_shape,
3735                                &baseline.command,
3736                            );
3737                            results.push(drift);
3738                        }
3739                        Err(e) => {
3740                            results.push(crate::introspection::ContractDrift {
3741                                command: baseline.command.clone(),
3742                                new_fields: Vec::new(),
3743                                removed_fields: Vec::new(),
3744                                type_changes: Vec::new(),
3745                                shape_matches: false,
3746                            });
3747                            tracing::warn!(
3748                                command = %baseline.command,
3749                                error = %e,
3750                                "contract check invocation failed"
3751                            );
3752                        }
3753                    }
3754                }
3755                let passing = results.iter().filter(|r| r.shape_matches).count();
3756                let result = serde_json::json!({
3757                    "checked": results.len(),
3758                    "passing": passing,
3759                    "failing": results.len() - passing,
3760                    "contracts": results,
3761                });
3762                json_result(&result)
3763            }
3764            IntrospectAction::ContractList => {
3765                let baselines = self.state.contract_store.all();
3766                let result = serde_json::json!({
3767                    "count": baselines.len(),
3768                    "baselines": baselines.iter().map(|b| serde_json::json!({
3769                        "command": b.command,
3770                        "shape_type": b.shape.type_name(),
3771                        "recorded_at": b.recorded_at,
3772                    })).collect::<Vec<_>>(),
3773                });
3774                json_result(&result)
3775            }
3776            IntrospectAction::ContractClear => {
3777                let cleared = self.state.contract_store.clear();
3778                json_result(&serde_json::json!({
3779                    "cleared": cleared,
3780                }))
3781            }
3782            IntrospectAction::StartupTiming => {
3783                let phases = self.state.startup_timeline.report();
3784                let result = serde_json::json!({
3785                    "phases": phases,
3786                    "total_ms": self.state.startup_timeline.total_ms(),
3787                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
3788                });
3789                json_result(&result)
3790            }
3791            IntrospectAction::Capabilities => {
3792                let config = self.bridge.tauri_config();
3793                // A busy UI is reported as such, never as "no live windows".
3794                let (live_windows, live_windows_error) = match self.bridge.try_list_window_labels()
3795                {
3796                    Ok(labels) => (Some(labels), None),
3797                    Err(e) => (None, Some(ui_busy(&e))),
3798                };
3799
3800                let result = serde_json::json!({
3801                    "app": {
3802                        "identifier": config.get("identifier"),
3803                        "product_name": config.get("product_name"),
3804                        "version": config.get("version"),
3805                    },
3806                    "security": config.get("security"),
3807                    "configured_windows": config.get("windows"),
3808                    "live_windows": live_windows,
3809                    "live_windows_error": live_windows_error,
3810                    "configured_plugins": config.get("plugins"),
3811                    "victauri": {
3812                        "registered_commands": self.state.registry.list().len(),
3813                        "redaction_enabled": self.state.privacy.redaction_enabled,
3814                        "privacy_profile": format!("{:?}", self.state.privacy.profile),
3815                        "disabled_tools": &self.state.privacy.disabled_tools,
3816                    },
3817                });
3818                json_result(&result)
3819            }
3820            #[allow(unused_variables)]
3821            IntrospectAction::DbHealth => {
3822                #[cfg(feature = "sqlite")]
3823                {
3824                    let db_path = params.db_path.clone();
3825                    match self.run_db_health(db_path.as_deref()).await {
3826                        Ok(health) => json_result(&health),
3827                        Err(e) => tool_error(format!("db_health failed: {e}")),
3828                    }
3829                }
3830                #[cfg(not(feature = "sqlite"))]
3831                {
3832                    tool_error("SQLite support not compiled in — enable the `sqlite` feature")
3833                }
3834            }
3835            IntrospectAction::PluginState => {
3836                let recording_active = self.state.recorder.is_recording();
3837                let recording_events = self.state.recorder.event_count();
3838                let result = serde_json::json!({
3839                    "event_log": {
3840                        "size": self.state.event_log.len(),
3841                        "capacity": self.state.event_log.capacity(),
3842                    },
3843                    "registry": {
3844                        "commands_registered": self.state.registry.list().len(),
3845                    },
3846                    "recording": {
3847                        "active": recording_active,
3848                        "events_captured": recording_events,
3849                    },
3850                    "faults": {
3851                        "active_rules": self.state.fault_registry.list().len(),
3852                    },
3853                    "contracts": {
3854                        "baselines_recorded": self.state.contract_store.all().len(),
3855                    },
3856                    "timings": {
3857                        "commands_profiled": self.state.command_timings.all_stats().len(),
3858                    },
3859                    "event_bus": {
3860                        "captured_events": self.state.event_bus.len(),
3861                    },
3862                    "tasks": {
3863                        "total": self.state.task_tracker.list().len(),
3864                        "active": self.state.task_tracker.active_count(),
3865                    },
3866                    "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
3867                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
3868                    "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
3869                });
3870                json_result(&result)
3871            }
3872            IntrospectAction::Processes => {
3873                let pid = std::process::id();
3874                let uptime = self.state.started_at.elapsed();
3875                let children = crate::introspection::enumerate_child_processes();
3876                let host_memory = crate::memory::current_stats();
3877
3878                let result = serde_json::json!({
3879                    "host": {
3880                        "pid": pid,
3881                        "uptime_secs": uptime.as_secs(),
3882                        "platform": std::env::consts::OS,
3883                        "arch": std::env::consts::ARCH,
3884                        "memory": host_memory,
3885                    },
3886                    "children": children.iter().map(|c| serde_json::json!({
3887                        "pid": c.pid,
3888                        "name": c.name,
3889                        "memory_bytes": c.memory_bytes,
3890                    })).collect::<Vec<_>>(),
3891                    "child_count": children.len(),
3892                    "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
3893                });
3894                json_result(&result)
3895            }
3896            IntrospectAction::PluginTasks => {
3897                let tasks = self.state.task_tracker.list();
3898                let active = self.state.task_tracker.active_count();
3899                let result = serde_json::json!({
3900                    "total": tasks.len(),
3901                    "active": active,
3902                    "finished": tasks.len() - active,
3903                    "tasks": tasks,
3904                });
3905                json_result(&result)
3906            }
3907            IntrospectAction::EventBus => {
3908                // Default cap so the full buffers (up to 1k Tauri + 10k app events, often
3909                // megabytes / tens of thousands of lines) can never overflow the tool result
3910                // cap (VIC-4). Newest events first; `count` is the full total so a truncated
3911                // slice is always diagnosable. Optional `limit` / `since_ms` are read from the
3912                // generic `args` object (a dedicated public field would be a semver-major break).
3913                let opts = params.args.as_ref();
3914                let limit = opts
3915                    .and_then(|a| a.get("limit"))
3916                    .and_then(serde_json::Value::as_u64)
3917                    .and_then(|n| usize::try_from(n).ok())
3918                    .unwrap_or(100);
3919                let since_ms = opts
3920                    .and_then(|a| a.get("since_ms"))
3921                    .and_then(serde_json::Value::as_u64);
3922                let cutoff = since_ms.map(|ms| bounded::ms_ago(chrono::Utc::now(), ms));
3923
3924                let all_tauri = self.state.event_bus.events();
3925                let tauri_total = all_tauri.len();
3926                let tauri_matched: Vec<_> = all_tauri
3927                    .into_iter()
3928                    .filter(|e| match cutoff {
3929                        Some(cut) => chrono::DateTime::parse_from_rfc3339(&e.timestamp)
3930                            .map_or(true, |t| t.with_timezone(&chrono::Utc) >= cut),
3931                        None => true,
3932                    })
3933                    .collect();
3934                let tauri_matched_count = tauri_matched.len();
3935                let tauri_events: Vec<_> = tauri_matched.into_iter().rev().take(limit).collect();
3936
3937                // Exclude Victauri's own infrastructure events (plugin:victauri|* IPC etc.) —
3938                // noise in a diagnostic timeline; the `explain` tools already filter them via
3939                // `is_internal()`.
3940                let all_app: Vec<_> = self
3941                    .state
3942                    .event_log
3943                    .snapshot()
3944                    .into_iter()
3945                    .filter(|e| !e.is_internal())
3946                    .collect();
3947                let app_total = all_app.len();
3948                let app_matched: Vec<_> = match cutoff {
3949                    Some(cut) => all_app
3950                        .into_iter()
3951                        .filter(|e| e.timestamp() >= cut)
3952                        .collect(),
3953                    None => all_app,
3954                };
3955                let app_matched_count = app_matched.len();
3956                let app_events: Vec<_> = app_matched.into_iter().rev().take(limit).collect();
3957
3958                let result = serde_json::json!({
3959                    "limit": limit,
3960                    "since_ms": since_ms,
3961                    "tauri_events": {
3962                        "count": tauri_total,
3963                        "matched": tauri_matched_count,
3964                        "returned": tauri_events.len(),
3965                        "truncated": tauri_matched_count > tauri_events.len(),
3966                        "events": tauri_events,
3967                    },
3968                    "app_events": {
3969                        "count": app_total,
3970                        "matched": app_matched_count,
3971                        "returned": app_events.len(),
3972                        "truncated": app_matched_count > app_events.len(),
3973                        "capacity": self.state.event_log.capacity(),
3974                        "events": app_events,
3975                    },
3976                });
3977                json_result(&result)
3978            }
3979            IntrospectAction::EventBusClear => {
3980                let tauri_cleared = self.state.event_bus.clear();
3981                self.state.event_log.clear();
3982                json_result(&serde_json::json!({
3983                    "tauri_events_cleared": tauri_cleared,
3984                    "app_events_cleared": true,
3985                }))
3986            }
3987        }
3988    }
3989
3990    // ── Fault Injection / Chaos Engineering ──────────────────────────────────
3991
3992    #[tool(
3993        description = "Probe a backend command handler under failure by faulting it for chaos engineering. \
3994            Simulate slow commands, backend errors, dropped responses, and corrupted data. \
3995            SCOPE: faults apply ONLY to commands you run via this server's `invoke_command` tool — \
3996            they do NOT touch the app's real user-driven IPC (window.__TAURI_INTERNALS__.invoke). Use \
3997            this to test a handler's error path when YOU drive it. To make a call the user's click \
3998            triggers fail or slow down, use a `route` rule on its IPC URL instead (fetch transport \
3999            only).\n\n\
4000            Actions:\n\
4001            - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms` \
4002              (max 120000), `error_message`, `max_triggers`. `delay` sleeps then runs the command; \
4003              `error` returns the error without running it; `drop` returns `{}` without running \
4004              it; `corrupt` runs it and replaces the response with a fixed \
4005              `{\"__corrupted\":true,…}` marker. Rules expire after 15 minutes.\n\
4006            - `list`: List all active fault injection rules.\n\
4007            - `clear`: Remove a specific fault rule (requires `command`).\n\
4008            - `clear_all`: Remove all fault rules.",
4009        annotations(
4010            read_only_hint = false,
4011            destructive_hint = true,
4012            idempotent_hint = false,
4013            open_world_hint = false
4014        )
4015    )]
4016    async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
4017        if !self.state.privacy.is_tool_enabled("fault") {
4018            return tool_disabled("fault");
4019        }
4020
4021        match params.action {
4022            FaultAction::Inject => {
4023                let Some(command) = params.command else {
4024                    return missing_param("command", "inject");
4025                };
4026                let Some(fault_kind) = params.fault_type else {
4027                    return missing_param("fault_type", "inject");
4028                };
4029                let fault_type = match fault_kind {
4030                    FaultKind::Delay => {
4031                        let delay_ms = params.delay_ms.unwrap_or(1000);
4032                        // Each delayed call holds a server concurrency slot for its duration;
4033                        // an unbounded delay (u64 ms) would pin slots effectively forever.
4034                        if delay_ms > MAX_FAULT_DELAY_MS {
4035                            return tool_error(format!(
4036                                "delay_ms {delay_ms} exceeds the maximum of {MAX_FAULT_DELAY_MS} ms"
4037                            ));
4038                        }
4039                        crate::introspection::FaultType::Delay { delay_ms }
4040                    }
4041                    FaultKind::Error => {
4042                        let message = params
4043                            .error_message
4044                            .unwrap_or_else(|| "injected fault".to_string());
4045                        crate::introspection::FaultType::Error { message }
4046                    }
4047                    FaultKind::Drop => crate::introspection::FaultType::Drop,
4048                    FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
4049                };
4050                let config = crate::introspection::FaultConfig {
4051                    command: command.clone(),
4052                    fault_type: fault_type.clone(),
4053                    trigger_count: 0,
4054                    max_triggers: params.max_triggers.unwrap_or(0),
4055                    created_at: std::time::Instant::now(),
4056                };
4057                self.state.fault_registry.inject(config);
4058                let result = serde_json::json!({
4059                    "injected": true,
4060                    "command": command,
4061                    "fault_type": fault_type,
4062                    "max_triggers": params.max_triggers.unwrap_or(0),
4063                });
4064                json_result(&result)
4065            }
4066            FaultAction::List => {
4067                let faults = self.state.fault_registry.list();
4068                let result = serde_json::json!({
4069                    "count": faults.len(),
4070                    "faults": faults.iter().map(|f| serde_json::json!({
4071                        "command": f.command,
4072                        "fault_type": f.fault_type,
4073                        "trigger_count": f.trigger_count,
4074                        "max_triggers": f.max_triggers,
4075                    })).collect::<Vec<_>>(),
4076                });
4077                json_result(&result)
4078            }
4079            FaultAction::Clear => {
4080                let Some(command) = params.command else {
4081                    return missing_param("command", "clear");
4082                };
4083                let removed = self.state.fault_registry.clear(&command);
4084                json_result(&serde_json::json!({
4085                    "removed": removed,
4086                    "command": command,
4087                }))
4088            }
4089            FaultAction::ClearAll => {
4090                let removed = self.state.fault_registry.clear_all();
4091                json_result(&serde_json::json!({
4092                    "removed": removed,
4093                }))
4094            }
4095        }
4096    }
4097
4098    // ── Cross-Layer Explanation ────────────────────────────────────────────
4099
4100    #[tool(
4101        description = "Correlate recent activity across all layers into a coherent narrative. \
4102            CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
4103            + window events across the Rust backend and webview simultaneously.\n\n\
4104            Actions:\n\
4105            - `summary`: High-level activity summary for the last N seconds (default 30). \
4106              Counts IPC calls, DOM mutations, console entries, state changes (incl. network \
4107              requests), errors. With no recording active it reads the window's live event \
4108              stream (`webview_label`, default main).\n\
4109            - `last_action`: Correlate the most recent burst of events into a causal timeline \
4110              (e.g. 'IPC call → DOM update → console.log').\n\
4111            - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
4112        annotations(
4113            read_only_hint = true,
4114            destructive_hint = false,
4115            idempotent_hint = true,
4116            open_world_hint = false
4117        )
4118    )]
4119    async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
4120        if !self.state.privacy.is_tool_enabled("explain") {
4121            return tool_disabled("explain");
4122        }
4123
4124        match params.action {
4125            ExplainAction::Summary => {
4126                let secs = params.seconds.unwrap_or(30);
4127                let since = bounded::secs_ago(chrono::Utc::now(), secs);
4128                let events = self
4129                    .explain_events(since, params.webview_label.as_deref())
4130                    .await;
4131
4132                let mut ipc_count = 0u64;
4133                let mut dom_mutations = 0u64;
4134                let mut state_changes = 0u64;
4135                let mut console_count = 0u64;
4136                let mut window_events = 0u64;
4137                let mut interactions = 0u64;
4138                let mut top_commands: HashMap<String, u64> = HashMap::new();
4139                let mut errors: Vec<String> = Vec::new();
4140
4141                for event in &events {
4142                    match event {
4143                        victauri_core::AppEvent::Ipc(call) => {
4144                            ipc_count += 1;
4145                            *top_commands.entry(call.command.clone()).or_insert(0) += 1;
4146                            if let victauri_core::IpcResult::Err(e) = &call.result {
4147                                errors.push(format!("IPC {}: {e}", call.command));
4148                            }
4149                        }
4150                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
4151                            dom_mutations += u64::from(*mutation_count)
4152                        }
4153                        victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
4154                        victauri_core::AppEvent::Console { level, message, .. } => {
4155                            console_count += 1;
4156                            if level == "error" {
4157                                errors.push(format!("console.error: {message}"));
4158                            }
4159                        }
4160                        victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
4161                        victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
4162                        _ => {}
4163                    }
4164                }
4165
4166                let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
4167                sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
4168                let top: Vec<_> = sorted_cmds.iter().take(5).collect();
4169
4170                let narrative = format!(
4171                    "{ipc_count} IPC call{} in the last {secs}s{}. \
4172                     {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
4173                     {console_count} console message{}, {window_events} window event{}. {}.",
4174                    if ipc_count == 1 { "" } else { "s" },
4175                    if top.is_empty() {
4176                        String::new()
4177                    } else {
4178                        format!(
4179                            ", dominated by {}",
4180                            top.iter()
4181                                .map(|(cmd, n)| format!("{cmd} ({n}x)"))
4182                                .collect::<Vec<_>>()
4183                                .join(", ")
4184                        )
4185                    },
4186                    if dom_mutations == 1 { "" } else { "s" },
4187                    if interactions == 1 { "" } else { "s" },
4188                    if console_count == 1 { "" } else { "s" },
4189                    if window_events == 1 { "" } else { "s" },
4190                    if errors.is_empty() {
4191                        "No errors".to_string()
4192                    } else {
4193                        format!(
4194                            "{} error{}",
4195                            errors.len(),
4196                            if errors.len() == 1 { "" } else { "s" }
4197                        )
4198                    },
4199                );
4200
4201                let result = serde_json::json!({
4202                    "time_window_secs": secs,
4203                    "total_events": events.len(),
4204                    "ipc_calls": ipc_count,
4205                    "dom_mutations": dom_mutations,
4206                    "state_changes": state_changes,
4207                    "console_messages": console_count,
4208                    "window_events": window_events,
4209                    "interactions": interactions,
4210                    "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
4211                        serde_json::json!({"command": cmd, "count": n})
4212                    }).collect::<Vec<_>>(),
4213                    "errors": errors,
4214                    "narrative": narrative,
4215                });
4216                json_result(&result)
4217            }
4218            ExplainAction::LastAction => {
4219                let secs = params.seconds.unwrap_or(5);
4220                let since = bounded::secs_ago(chrono::Utc::now(), secs);
4221                let events = self
4222                    .explain_events(since, params.webview_label.as_deref())
4223                    .await;
4224
4225                let timeline: Vec<serde_json::Value> = events
4226                    .iter()
4227                    .filter(|e| !e.is_internal())
4228                    .map(|event| match event {
4229                        victauri_core::AppEvent::Ipc(call) => serde_json::json!({
4230                            "time": call.timestamp.to_rfc3339_opts(
4231                                chrono::SecondsFormat::Millis, true
4232                            ),
4233                            "type": "ipc",
4234                            "detail": format!(
4235                                "{} {} ({}ms)",
4236                                call.command,
4237                                call.result,
4238                                call.duration_ms.unwrap_or(0)
4239                            ),
4240                        }),
4241                        victauri_core::AppEvent::DomMutation {
4242                            timestamp,
4243                            mutation_count,
4244                            webview_label,
4245                            ..
4246                        } => serde_json::json!({
4247                            "time": timestamp.to_rfc3339_opts(
4248                                chrono::SecondsFormat::Millis, true
4249                            ),
4250                            "type": "dom_mutation",
4251                            "detail": format!(
4252                                "{mutation_count} element{} updated in {webview_label}",
4253                                if *mutation_count == 1 { "" } else { "s" }
4254                            ),
4255                        }),
4256                        victauri_core::AppEvent::DomInteraction {
4257                            timestamp,
4258                            action,
4259                            selector,
4260                            ..
4261                        } => serde_json::json!({
4262                            "time": timestamp.to_rfc3339_opts(
4263                                chrono::SecondsFormat::Millis, true
4264                            ),
4265                            "type": "interaction",
4266                            "detail": format!("{action} on {selector}"),
4267                        }),
4268                        victauri_core::AppEvent::StateChange {
4269                            timestamp,
4270                            key,
4271                            caused_by,
4272                            ..
4273                        } => serde_json::json!({
4274                            "time": timestamp.to_rfc3339_opts(
4275                                chrono::SecondsFormat::Millis, true
4276                            ),
4277                            "type": "state_change",
4278                            "detail": format!(
4279                                "{key} changed{}",
4280                                caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
4281                            ),
4282                        }),
4283                        victauri_core::AppEvent::Console {
4284                            timestamp,
4285                            level,
4286                            message,
4287                            ..
4288                        } => serde_json::json!({
4289                            "time": timestamp.to_rfc3339_opts(
4290                                chrono::SecondsFormat::Millis, true
4291                            ),
4292                            "type": "console",
4293                            "detail": format!("console.{level}: {message}"),
4294                        }),
4295                        victauri_core::AppEvent::WindowEvent {
4296                            timestamp,
4297                            label,
4298                            event,
4299                            ..
4300                        } => serde_json::json!({
4301                            "time": timestamp.to_rfc3339_opts(
4302                                chrono::SecondsFormat::Millis, true
4303                            ),
4304                            "type": "window_event",
4305                            "detail": format!("{event} on window '{label}'"),
4306                        }),
4307                        _ => serde_json::json!({
4308                            "time": event.timestamp().to_rfc3339_opts(
4309                                chrono::SecondsFormat::Millis, true
4310                            ),
4311                            "type": "other",
4312                            "detail": "unknown event type",
4313                        }),
4314                    })
4315                    .collect();
4316
4317                let narrative = if timeline.is_empty() {
4318                    format!("No activity in the last {secs}s.")
4319                } else {
4320                    let parts: Vec<String> = timeline
4321                        .iter()
4322                        .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
4323                        .map(String::from)
4324                        .collect();
4325                    parts.join(" → ")
4326                };
4327
4328                let result = serde_json::json!({
4329                    "time_window_secs": secs,
4330                    "event_count": timeline.len(),
4331                    "timeline": timeline,
4332                    "narrative": narrative,
4333                });
4334                json_result(&result)
4335            }
4336            ExplainAction::Diff => {
4337                let secs = params.seconds.unwrap_or(10);
4338                let since = bounded::secs_ago(chrono::Utc::now(), secs);
4339                let events = self
4340                    .explain_events(since, params.webview_label.as_deref())
4341                    .await;
4342
4343                let mut ipc_commands: Vec<String> = Vec::new();
4344                let mut dom_changes = 0u64;
4345                let mut error_count = 0u64;
4346                let mut interaction_count = 0u64;
4347                let mut console_messages = 0u64;
4348
4349                for event in &events {
4350                    if event.is_internal() {
4351                        continue;
4352                    }
4353                    match event {
4354                        victauri_core::AppEvent::Ipc(call) => {
4355                            ipc_commands.push(call.command.clone());
4356                            if matches!(call.result, victauri_core::IpcResult::Err(_)) {
4357                                error_count += 1;
4358                            }
4359                        }
4360                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
4361                            dom_changes += u64::from(*mutation_count)
4362                        }
4363                        victauri_core::AppEvent::DomInteraction { .. } => {
4364                            interaction_count += 1;
4365                        }
4366                        victauri_core::AppEvent::Console { level, .. } => {
4367                            console_messages += 1;
4368                            if level == "error" {
4369                                error_count += 1;
4370                            }
4371                        }
4372                        _ => {}
4373                    }
4374                }
4375
4376                // Every call counts; each command is listed once, in first-call order (a
4377                // consecutive-only `dedup` used to undercount the calls and still list a
4378                // command once per non-adjacent run).
4379                let ipc_calls_made = ipc_commands.len();
4380                let mut seen = HashSet::new();
4381                ipc_commands.retain(|c| seen.insert(c.clone()));
4382
4383                let result = serde_json::json!({
4384                    "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
4385                    "time_window_secs": secs,
4386                    "total_events": events.len(),
4387                    "ipc_calls_made": ipc_calls_made,
4388                    "unique_commands": ipc_commands,
4389                    "dom_elements_changed": dom_changes,
4390                    "interactions": interaction_count,
4391                    "console_messages": console_messages,
4392                    "errors": error_count,
4393                });
4394                json_result(&result)
4395            }
4396        }
4397    }
4398}
4399
4400impl VictauriMcpHandler {
4401    /// Create a new handler backed by the given state and webview bridge.
4402    pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
4403        Self {
4404            state,
4405            bridge,
4406            subscriptions: Arc::new(Mutex::new(HashSet::new())),
4407            bridge_checked: Arc::new(AtomicBool::new(false)),
4408            timed_out_labels: Arc::new(Mutex::new(HashSet::new())),
4409            probe_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_PROBES)),
4410            file_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_FILE_READS)),
4411            probe_timeout: PROBE_TIMEOUT,
4412            file_read_timeout: READ_APP_FILE_TIMEOUT,
4413        }
4414    }
4415
4416    pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
4417        self.state.privacy.is_tool_enabled(name)
4418    }
4419
4420    pub(crate) async fn execute_tool(
4421        &self,
4422        name: &str,
4423        args: serde_json::Value,
4424    ) -> Result<CallToolResult, rest::ToolCallError> {
4425        // Centralized authorization: resolve the canonical `tool.action` capability
4426        // and gate on it BEFORE dispatch, so every compound action is checked
4427        // uniformly (not just the ones whose handler remembers to). See `authz`.
4428        // A disabled tool reports "disabled" whatever its arguments look like.
4429        if self.state.privacy.disabled_tools.contains(name) {
4430            return Ok(tool_disabled(name));
4431        }
4432        let capability =
4433            authz::resolve_capability(name, &args).map_err(rest::ToolCallError::InvalidParams)?;
4434        if !self.state.privacy.is_call_allowed(name, &capability) {
4435            return Ok(tool_disabled(&capability));
4436        }
4437        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
4438        let start = std::time::Instant::now();
4439        tracing::debug!(tool = %name, "REST tool invocation started");
4440
4441        // A panicking handler becomes an error RESULT; without this boundary it unwound into
4442        // hyper's connection task and the client saw a reset connection.
4443        let result = match bounded::CatchUnwind::new(self.dispatch_tool(name, args)).await {
4444            Ok(dispatched) => dispatched?,
4445            Err(panic) => {
4446                tracing::error!(tool = %name, "tool handler panicked: {panic}");
4447                tool_panicked(name, &panic)
4448            }
4449        };
4450
4451        let elapsed = start.elapsed();
4452        tracing::debug!(
4453            tool = %name,
4454            elapsed_ms = elapsed.as_millis() as u64,
4455            "REST tool invocation completed"
4456        );
4457
4458        if self.state.privacy.redaction_enabled {
4459            Ok(Self::redact_result(result, &self.state.privacy))
4460        } else {
4461            Ok(result)
4462        }
4463    }
4464
4465    /// Route one already-authorized REST call to its tool handler.
4466    async fn dispatch_tool(
4467        &self,
4468        name: &str,
4469        args: serde_json::Value,
4470    ) -> Result<CallToolResult, rest::ToolCallError> {
4471        let result = match name {
4472            "eval_js" => {
4473                let p: EvalJsParams = Self::parse_args(args)?;
4474                self.eval_js(Parameters(p)).await
4475            }
4476            "dom_snapshot" => {
4477                let p: SnapshotParams = Self::parse_args(args)?;
4478                self.dom_snapshot(Parameters(p)).await
4479            }
4480            "find_elements" => {
4481                let p: FindElementsParams = Self::parse_args(args)?;
4482                self.find_elements(Parameters(p)).await
4483            }
4484            "invoke_command" => {
4485                let p: InvokeCommandParams = Self::parse_args(args)?;
4486                self.invoke_command(Parameters(p)).await
4487            }
4488            "screenshot" => {
4489                let p: ScreenshotParams = Self::parse_args(args)?;
4490                self.screenshot(Parameters(p)).await
4491            }
4492            "verify_state" => {
4493                let p: VerifyStateParams = Self::parse_args(args)?;
4494                self.verify_state(Parameters(p)).await
4495            }
4496            "detect_ghost_commands" => {
4497                let p: GhostCommandParams = Self::parse_args(args)?;
4498                self.detect_ghost_commands(Parameters(p)).await
4499            }
4500            "check_ipc_integrity" => {
4501                let p: IpcIntegrityParams = Self::parse_args(args)?;
4502                self.check_ipc_integrity(Parameters(p)).await
4503            }
4504            "wait_for" => {
4505                let p: WaitForParams = Self::parse_args(args)?;
4506                self.wait_for(Parameters(p)).await
4507            }
4508            "assert_semantic" => {
4509                let p: SemanticAssertParams = Self::parse_args(args)?;
4510                self.assert_semantic(Parameters(p)).await
4511            }
4512            "resolve_command" => {
4513                let p: ResolveCommandParams = Self::parse_args(args)?;
4514                self.resolve_command(Parameters(p)).await
4515            }
4516            "get_registry" => {
4517                let p: RegistryParams = Self::parse_args(args)?;
4518                self.get_registry(Parameters(p)).await
4519            }
4520            "app_state" => {
4521                let p: AppStateParams = Self::parse_args(args)?;
4522                self.app_state(Parameters(p)).await
4523            }
4524            "get_memory_stats" => self.get_memory_stats().await,
4525            "get_plugin_info" => self.get_plugin_info().await,
4526            "get_diagnostics" => {
4527                let p: DiagnosticsParams = Self::parse_args(args)?;
4528                self.get_diagnostics(Parameters(p)).await
4529            }
4530            "app_info" => self.app_info().await,
4531            "list_app_dir" => {
4532                let p: ListAppDirParams = Self::parse_args(args)?;
4533                self.list_app_dir(Parameters(p)).await
4534            }
4535            "read_app_file" => {
4536                let p: ReadAppFileParams = Self::parse_args(args)?;
4537                self.read_app_file(Parameters(p)).await
4538            }
4539            "query_db" => {
4540                let p: QueryDbParams = Self::parse_args(args)?;
4541                self.query_db(Parameters(p)).await
4542            }
4543            "interact" => {
4544                let p: InteractParams = Self::parse_args(args)?;
4545                self.interact(Parameters(p)).await
4546            }
4547            "input" => {
4548                let p: InputParams = Self::parse_args(args)?;
4549                self.input(Parameters(p)).await
4550            }
4551            "window" => {
4552                let p: WindowParams = Self::parse_args(args)?;
4553                self.window(Parameters(p)).await
4554            }
4555            "storage" => {
4556                let p: StorageParams = Self::parse_args(args)?;
4557                self.storage(Parameters(p)).await
4558            }
4559            "navigate" => {
4560                let p: NavigateParams = Self::parse_args(args)?;
4561                self.navigate(Parameters(p)).await
4562            }
4563            "recording" => {
4564                let p: RecordingParams = Self::parse_args(args)?;
4565                self.recording(Parameters(p)).await
4566            }
4567            "inspect" => {
4568                let p: InspectParams = Self::parse_args(args)?;
4569                self.inspect(Parameters(p)).await
4570            }
4571            "css" => {
4572                let p: CssParams = Self::parse_args(args)?;
4573                self.css(Parameters(p)).await
4574            }
4575            "route" => {
4576                let p: RouteParams = Self::parse_args(args)?;
4577                self.route(Parameters(p)).await
4578            }
4579            "trace" => {
4580                let p: TraceParams = Self::parse_args(args)?;
4581                self.trace(Parameters(p)).await
4582            }
4583            "animation" => {
4584                let p: AnimationParams = Self::parse_args(args)?;
4585                self.animation(Parameters(p)).await
4586            }
4587            "logs" => {
4588                let p: LogsParams = Self::parse_args(args)?;
4589                self.logs(Parameters(p)).await
4590            }
4591            "introspect" => {
4592                let p: IntrospectParams = Self::parse_args(args)?;
4593                self.introspect(Parameters(p)).await
4594            }
4595            "fault" => {
4596                let p: FaultParams = Self::parse_args(args)?;
4597                self.fault(Parameters(p)).await
4598            }
4599            "explain" => {
4600                let p: ExplainParams = Self::parse_args(args)?;
4601                self.explain(Parameters(p)).await
4602            }
4603            _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
4604        };
4605        Ok(result)
4606    }
4607
4608    fn parse_args<T: serde::de::DeserializeOwned>(
4609        args: serde_json::Value,
4610    ) -> Result<T, rest::ToolCallError> {
4611        serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
4612    }
4613
4614    fn redact_result(
4615        mut result: CallToolResult,
4616        privacy: &crate::privacy::PrivacyConfig,
4617    ) -> CallToolResult {
4618        for item in &mut result.content {
4619            if let ContentBlock::Text(tc) = item {
4620                tc.text = privacy.redact_output(&tc.text);
4621            }
4622        }
4623        result
4624    }
4625
4626    /// Read at most `max_bytes` (+1, to detect truncation) of the regular file at `path` on
4627    /// the blocking pool, within [`READ_APP_FILE_TIMEOUT`] and one of the
4628    /// [`MAX_CONCURRENT_FILE_READS`] slots. A read that blocks (a FIFO or device swapped in
4629    /// after the handler's checks) returns at the deadline; its thread keeps the slot until
4630    /// it finishes, so such reads cannot pile up.
4631    async fn read_regular_file_bounded(
4632        &self,
4633        path: std::path::PathBuf,
4634        max_bytes: usize,
4635    ) -> Result<(Vec<u8>, usize, Option<u64>), String> {
4636        let Ok(slot) = Arc::clone(&self.file_slots).try_acquire_owned() else {
4637            return Err(format!(
4638                "file reads are busy ({MAX_CONCURRENT_FILE_READS} still running — a read \
4639                 blocked on a pipe or device keeps running past its timeout). Retry shortly."
4640            ));
4641        };
4642        bounded::run_blocking_bounded(None, "file read", self.file_read_timeout, move || {
4643            let _slot = slot;
4644            read_regular_file(&path, max_bytes)
4645        })
4646        .await
4647    }
4648
4649    fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
4650        match dir.unwrap_or(AppDir::Data) {
4651            AppDir::Data => self.bridge.app_data_dir(),
4652            AppDir::Config => self.bridge.app_config_dir(),
4653            AppDir::Log => self.bridge.app_log_dir(),
4654            AppDir::LocalData => self.bridge.app_local_data_dir(),
4655        }
4656    }
4657
4658    /// Lexical (pre-existence) traversal guard for a user-supplied sub-path.
4659    ///
4660    /// Rejects absolute paths and any component that is `..` BEFORE the path is
4661    /// canonicalized. This is necessary because [`Self::safe_within`] relies on
4662    /// `canonicalize`, which errors on non-existent paths — so a traversal
4663    /// attempt against a missing target would otherwise be reported as
4664    /// "not found" (an info-leak oracle) rather than as traversal.
4665    fn lexical_safe(sub: &std::path::Path) -> Result<(), String> {
4666        use std::path::Component;
4667        if sub.is_absolute() {
4668            return Err("path traversal not allowed: absolute paths are rejected".to_string());
4669        }
4670        for component in sub.components() {
4671            match component {
4672                Component::ParentDir => {
4673                    return Err("path traversal not allowed: '..' is rejected".to_string());
4674                }
4675                Component::Prefix(_) | Component::RootDir => {
4676                    return Err(
4677                        "path traversal not allowed: absolute paths are rejected".to_string()
4678                    );
4679                }
4680                Component::CurDir | Component::Normal(_) => {}
4681            }
4682        }
4683        Ok(())
4684    }
4685
4686    fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
4687        let canon_base = std::fs::canonicalize(base)
4688            .map_err(|e| format!("cannot resolve base directory: {e}"))?;
4689        let canon_target = std::fs::canonicalize(target)
4690            .map_err(|e| format!("cannot resolve target path: {e}"))?;
4691        if !canon_target.starts_with(&canon_base) {
4692            return Err("path traversal not allowed".to_string());
4693        }
4694        Ok(())
4695    }
4696
4697    #[cfg(feature = "sqlite")]
4698    fn resolve_existing_db_path(
4699        roots: &[std::path::PathBuf],
4700        requested: &str,
4701    ) -> Result<std::path::PathBuf, String> {
4702        let candidate = std::path::Path::new(requested);
4703        // One answer for "missing" and "resolves outside every root" (a symlink inside a root
4704        // pointing elsewhere): distinct answers told the caller whether an arbitrary path
4705        // exists on disk.
4706        let not_found = || {
4707            format!(
4708                "database not found (or it resolves outside the allowed directories): {requested}"
4709            )
4710        };
4711        if candidate.is_absolute() {
4712            // `root/link/../y` normalizes lexically to `root/y`, but the OS resolves `..` AFTER
4713            // following `link` — so the existence check probed a path outside every root.
4714            if candidate
4715                .components()
4716                .any(|c| matches!(c, std::path::Component::ParentDir))
4717            {
4718                return Err("path traversal not allowed: '..' is rejected".to_string());
4719            }
4720            // Decide containment LEXICALLY first, before touching the requested path: checking
4721            // existence first answered "does X exist?" for any path on disk ("not found" vs
4722            // "not within an allowed directory"). The canonical check below still catches
4723            // symlink escapes for paths that are lexically inside a root.
4724            let normalized = lexically_normalize(candidate);
4725            let lexically_inside = roots.iter().any(|root| {
4726                normalized.starts_with(lexically_normalize(root))
4727                    || std::fs::canonicalize(root)
4728                        .is_ok_and(|canon_root| normalized.starts_with(canon_root))
4729            });
4730            if !lexically_inside {
4731                return Err(format!(
4732                    "absolute path '{requested}' is not within an allowed directory; \
4733                     register its parent via VictauriBuilder::db_search_paths"
4734                ));
4735            }
4736            if candidate.exists()
4737                && roots
4738                    .iter()
4739                    .any(|root| Self::safe_within(root, candidate).is_ok())
4740            {
4741                // Open the CANONICAL validated path, not the caller's literal absolute path,
4742                // so the DB is opened at exactly the containment-approved location — symmetric
4743                // with the relative branch below and closing the validate-canonical/open-lexical
4744                // TOCTOU on this branch (a same-privilege symlink swap between canonicalize and
4745                // open is the unavoidable residual, documented in security.md).
4746                let canonical = std::fs::canonicalize(candidate)
4747                    .map_err(|e| format!("cannot resolve database path: {e}"))?;
4748                return Ok(canonical);
4749            }
4750            return Err(not_found());
4751        }
4752
4753        Self::lexical_safe(candidate)?;
4754        for root in roots {
4755            let resolved = root.join(candidate);
4756            // A match that escapes its root is treated exactly like a miss (see `not_found`).
4757            if resolved.exists() && Self::safe_within(root, &resolved).is_ok() {
4758                // Open the CANONICAL validated path, not the lexical join, so the DB is opened
4759                // at exactly the path containment approved (closes the trivial validate-lexical
4760                // vs open-lexical TOCTOU; a same-privilege local symlink swap between
4761                // canonicalize and open is the unavoidable residual, documented in security.md).
4762                let canonical = std::fs::canonicalize(&resolved)
4763                    .map_err(|e| format!("cannot resolve database path: {e}"))?;
4764                return Ok(canonical);
4765            }
4766        }
4767
4768        let roots = roots
4769            .iter()
4770            .map(|root| root.display().to_string())
4771            .collect::<Vec<_>>()
4772            .join(", ");
4773        Err(format!("{} (searched: {roots})", not_found()))
4774    }
4775
4776    /// Events for `explain` since `since`. While a recording is active the background drain
4777    /// keeps `event_log` current, so read it. Otherwise nothing drains (idle draining was the
4778    /// 0.8.x host-crash amplifier), so read the bridge's own event stream ON DEMAND — without
4779    /// writing it into `event_log`, so a later recording never double-counts it. Before this,
4780    /// `explain` silently reported "0 IPC calls, 0 DOM mutations…" for any app not recording.
4781    async fn explain_events(
4782        &self,
4783        since: chrono::DateTime<chrono::Utc>,
4784        webview_label: Option<&str>,
4785    ) -> Vec<victauri_core::AppEvent> {
4786        if self.state.recorder.is_recording() {
4787            return self.state.event_log.since(since);
4788        }
4789        let code = format!(
4790            "return window.__VICTAURI__?.getEventStream({})",
4791            since.timestamp_millis()
4792        );
4793        let Ok(raw) = self.eval_with_return(&code, webview_label).await else {
4794            return self.state.event_log.since(since);
4795        };
4796        let label = webview_label.unwrap_or("main");
4797        let mut events: Vec<victauri_core::AppEvent> =
4798            page_json::parse_page_json::<Vec<serde_json::Value>>(&raw)
4799                .unwrap_or_default()
4800                .iter()
4801                .filter_map(|ev| crate::mcp::server::parse_bridge_event_from(ev, label))
4802                .filter(|ev| !ev.is_internal() && ev.timestamp() >= since)
4803                .collect();
4804        events.sort_by_key(victauri_core::AppEvent::timestamp);
4805        events
4806    }
4807
4808    /// Resolve the EXACT window a native capture (`screenshot`, `trace`) should target, and
4809    /// require it to be visible. A native capture reads the on-screen surface; a hidden window
4810    /// has none, so the OS path (`PrintWindow` / `CGWindowListCreateImage`) silently returns
4811    /// stale, empty, or ANOTHER window's pixels with no error. Two failure modes are closed:
4812    ///   1. (live-4DA, 2026-06-16) an explicitly-requested hidden window (label:"briefing")
4813    ///      returned the MAIN window's pixels.
4814    ///   2. (GPT audit, P2) with no label, `find_window(None)` prefers "main" UNCONDITIONALLY,
4815    ///      so an app that hides main but leaves a secondary window visible captured hidden
4816    ///      main. `find_window` is left alone — other callers (e.g. eval) may legitimately
4817    ///      target a hidden window — so capture tools resolve their own VISIBLE target.
4818    ///
4819    /// Acknowledged residual: resolve -> handle -> capture are separate calls, so a window
4820    /// hidden in the gap is still TOCTOU; the worst case is a wrong image, not a security
4821    /// boundary.
4822    fn resolve_visible_capture_target(&self, label: Option<&str>) -> Result<String, String> {
4823        let states = self
4824            .bridge
4825            .try_get_window_states(None)
4826            .map_err(|e| ui_busy(&e))?;
4827        if let Some(label) = label {
4828            // An explicit label that the enumerator reports hidden is rejected; visible or
4829            // unknown labels fall through (an unknown one lets get_native_handle produce the
4830            // canonical "window not found" rather than inventing an error here).
4831            if states.iter().any(|s| s.label == label && !s.visible) {
4832                return Err(format!(
4833                    "window '{label}' is not visible — a native screenshot captures the \
4834                     on-screen surface, and a hidden window has none (the OS capture would \
4835                     return stale or another window's pixels). Show it first \
4836                     (window action=manage manage_action=show label={label}), then capture."
4837                ));
4838            }
4839            return Ok(label.to_string());
4840        }
4841        // No label: prefer a visible "main", else the first visible window. NEVER silently
4842        // fall back to a hidden window (the P2 bug) — error instead.
4843        states
4844            .iter()
4845            .find(|s| s.label == "main" && s.visible)
4846            .or_else(|| states.iter().find(|s| s.visible))
4847            .map(|st| st.label.clone())
4848            .ok_or_else(|| {
4849                "no visible window to capture — every window is hidden (or the UI is \
4850                 not responding). Show one first (window action=manage \
4851                 manage_action=show label=<label>), then capture."
4852                    .to_string()
4853            })
4854    }
4855
4856    /// Whether `target` exists, after refusing it if it resolves outside `base`.
4857    ///
4858    /// For a missing target, containment is decided on its deepest EXISTING ancestor, so a
4859    /// path routed through a symlink out of `base` is refused whether or not its final
4860    /// component exists ("missing" vs "outside" would otherwise reveal whether an arbitrary
4861    /// outside path exists).
4862    fn contained_or_missing(
4863        base: &std::path::Path,
4864        target: &std::path::Path,
4865    ) -> Result<bool, String> {
4866        // A missing base holds nothing (and no symlink that could lead out of it).
4867        let Ok(canon_base) = std::fs::canonicalize(base) else {
4868            return Ok(false);
4869        };
4870        let exists = target.exists();
4871        let probe = if exists {
4872            Some(target)
4873        } else {
4874            target.ancestors().skip(1).find(|a| a.exists())
4875        };
4876        if let Some(probe) = probe {
4877            let canonical = std::fs::canonicalize(probe)
4878                .map_err(|e| format!("cannot resolve target path: {e}"))?;
4879            if !canonical.starts_with(&canon_base) {
4880                return Err("path traversal not allowed".to_string());
4881            }
4882        }
4883        Ok(exists)
4884    }
4885
4886    fn matches_glob(name: &str, pattern: &str) -> bool {
4887        if pattern == "*" {
4888            return true;
4889        }
4890        if let Some(suffix) = pattern.strip_prefix("*.") {
4891            return name.ends_with(&format!(".{suffix}"));
4892        }
4893        if let Some(prefix) = pattern.strip_suffix("*") {
4894            return name.starts_with(prefix);
4895        }
4896        name == pattern
4897    }
4898
4899    /// Probe every window's JS bridge and report which are introspectable. A
4900    /// visible window that fails to respond almost always lacks the
4901    /// `victauri:default` capability — Tauri's permission ACL silently blocks
4902    /// the bridge's callback IPC, so eval/dom/animation tools see nothing. This
4903    /// turns that silent dead-end into an actionable, up-front diagnosis.
4904    async fn window_introspectability(&self) -> CallToolResult {
4905        let labels = match self.bridge.try_list_window_labels() {
4906            Ok(labels) => labels,
4907            Err(e) => return tool_error(ui_busy(&e)),
4908        };
4909        let states = match self.bridge.try_get_window_states(None) {
4910            Ok(states) => states,
4911            Err(e) => return tool_error(ui_busy(&e)),
4912        };
4913        let mut report = Vec::with_capacity(labels.len());
4914        let mut blind = 0usize;
4915        for label in &labels {
4916            let visible = states.iter().find(|s| &s.label == label).map(|s| s.visible);
4917            let introspectable = self.probe_bridge(Some(label), None, None).await.is_ok();
4918            if !introspectable {
4919                blind += 1;
4920            }
4921            let note = if introspectable {
4922                "ok — Victauri JS bridge is responding".to_string()
4923            } else if visible == Some(true) {
4924                format!(
4925                    "NOT introspectable although the window is visible — almost certainly missing \
4926                     the Victauri capability. Add \"victauri:default\" to the capability file \
4927                     (src-tauri/capabilities/*.json) whose \"windows\" list includes \"{label}\", \
4928                     then rebuild. Capabilities are baked at compile time, so a rebuild is required."
4929                )
4930            } else {
4931                "NOT introspectable (window is hidden and/or has no bridge) — show the window to \
4932                 confirm, and ensure its capability includes \"victauri:default\", then rebuild."
4933                    .to_string()
4934            };
4935            report.push(serde_json::json!({
4936                "label": label,
4937                "visible": visible,
4938                "introspectable": introspectable,
4939                "note": note,
4940            }));
4941        }
4942        let hint = if blind > 0 {
4943            "Windows with introspectable:false have no working Victauri JS bridge — eval_js, \
4944             dom_snapshot, animation, find_elements, etc. cannot see them. The usual cause is a \
4945             missing \"victauri:default\" capability for that window: Tauri's per-window permission \
4946             ACL silently blocks the bridge's callback IPC. This capability is required per window, \
4947             not just for the main window. (Note: probing a blind window takes ~2s each.)"
4948        } else {
4949            "All windows are introspectable."
4950        };
4951        json_result(&serde_json::json!({
4952            "windows": report,
4953            "introspectable_count": labels.len().saturating_sub(blind),
4954            "blind_count": blind,
4955            "hint": hint,
4956        }))
4957    }
4958
4959    /// Focus `ref_id` before trusted (OS-level) keystrokes and confirm focus LANDED on it —
4960    /// the keys go to whatever holds focus, so an element that exists but did not take focus
4961    /// (not focusable, inert, or a focus handler moved focus on) must stop the input.
4962    async fn focus_for_trusted_input(
4963        &self,
4964        ref_id: &str,
4965        webview_label: Option<&str>,
4966    ) -> Result<(), CallToolResult> {
4967        let raw = self
4968            .eval_with_return(&trusted_focus_probe_js(ref_id), webview_label)
4969            .await
4970            .map_err(|e| {
4971                tool_error(format!(
4972                    "could not focus {ref_id} before sending OS input: {e} — no keys were sent"
4973                ))
4974            })?;
4975        let answer = serde_json::from_str::<serde_json::Value>(&raw).unwrap_or_default();
4976        if answer.get("focused") == Some(&serde_json::Value::Bool(true)) {
4977            return Ok(());
4978        }
4979        let why = if answer.get("found") == Some(&serde_json::Value::Bool(true)) {
4980            "focus did not land on it (not focusable, inert, or a focus handler moved focus \
4981             elsewhere)"
4982        } else {
4983            "ref not found"
4984        };
4985        Err(tool_error_with_hint(
4986            format!(
4987                "ref not found or not focusable: {ref_id}: {why} — no keys were sent (they \
4988                 would have gone to whatever holds focus)"
4989            ),
4990            RecoveryHint::CheckInput,
4991        ))
4992    }
4993
4994    /// The window's IPC capture status (`getIpcCaptureStatus`): which Tauri IPC transport the
4995    /// page uses and how completely the bridge captures it. Best effort — `null` when it cannot
4996    /// be read (an older bridge, an unreachable window) — so it never fails the tool it
4997    /// annotates. Every IPC-log-derived report carries it, so a report built from a partial
4998    /// IPC picture says so instead of looking complete.
4999    async fn ipc_capture_status(&self, webview_label: Option<&str>) -> serde_json::Value {
5000        match self
5001            .eval_with_return_timeout(
5002                IPC_CAPTURE_STATUS_JS,
5003                webview_label,
5004                std::time::Duration::from_secs(5),
5005            )
5006            .await
5007        {
5008            Ok(raw) => page_json::parse_page_json::<serde_json::Value>(&raw)
5009                .ok()
5010                .filter(serde_json::Value::is_object)
5011                .unwrap_or(serde_json::Value::Null),
5012            Err(_) => serde_json::Value::Null,
5013        }
5014    }
5015
5016    async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
5017        match self.eval_with_return(code, webview_label).await {
5018            Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
5019            Err(e) => tool_error(e),
5020        }
5021    }
5022
5023    /// Run a page ACTION (`interact`, `input`, `inspect`, `route add`) and report a failure the
5024    /// page returns — `{ok: false, error, hint}` or `{error}` — as a tool error carrying the
5025    /// page's message and recovery hint. These used to come back as a SUCCESS result, so a
5026    /// refused click (covered, disabled, ref not found) read as done unless the caller parsed
5027    /// the body (R5B-ISERR1).
5028    async fn eval_page_action(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
5029        match self.eval_with_return(code, webview_label).await {
5030            Ok(result) => match page_action_error(&result) {
5031                Some(message) => tool_error(message),
5032                None => CallToolResult::success(vec![ContentBlock::text(result)]),
5033            },
5034            Err(e) => tool_error(e),
5035        }
5036    }
5037
5038    async fn eval_with_return(
5039        &self,
5040        code: &str,
5041        webview_label: Option<&str>,
5042    ) -> Result<String, String> {
5043        self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
5044            .await
5045    }
5046
5047    /// Atomically reserve a pending-eval slot under a SINGLE lock: reject if the map is
5048    /// already at the concurrency ceiling, otherwise insert. This makes `MAX_PENDING_EVALS`
5049    /// a TRUE hard ceiling — a separate check-then-insert races (concurrent callers all pass
5050    /// a stale check, then each inserts, blowing past the cap). On a saturated map it also
5051    /// fails fast (before any eval is injected) with the real "too many concurrent" cause
5052    /// rather than letting a probe burn its full timeout. The slot is released when the
5053    /// returned guard drops — on every exit, including the caller's future being dropped.
5054    async fn reserve_pending(
5055        &self,
5056        id: &str,
5057        tx: tokio::sync::oneshot::Sender<String>,
5058    ) -> Result<crate::PendingSlot, String> {
5059        let mut pending = self.state.pending_evals.lock().await;
5060        if pending.len() >= MAX_PENDING_EVALS {
5061            return Err(format!(
5062                "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
5063            ));
5064        }
5065        Ok(crate::PendingSlot::insert(
5066            &self.state.pending_evals,
5067            &mut pending,
5068            id.to_string(),
5069            tx,
5070        ))
5071    }
5072
5073    /// Liveness probe. On success, returns the nonce of the page that answered (`None` for a
5074    /// page without the Victauri bridge).
5075    /// Within an agent call, `deadline` bounds the probe's main-thread DISPATCH by the time that
5076    /// call has left (none is started once it has passed). The wait for the answer is the
5077    /// probe's own [`PROBE_ANSWER_WINDOW`] unless `answer_by` cuts it shorter — only for callers
5078    /// that treat a failed probe as "no evidence" (`page_replaced`): a cut-short probe cannot
5079    /// tell a dead page from a busy one, so the probe that decides "bridge not responding" always
5080    /// gets its full window (calling a healthy page dead sends the agent hunting for a missing
5081    /// capability).
5082    async fn probe_bridge(
5083        &self,
5084        webview_label: Option<&str>,
5085        deadline: Option<tokio::time::Instant>,
5086        answer_by: Option<tokio::time::Instant>,
5087    ) -> Result<Option<String>, String> {
5088        if deadline.is_some_and(|d| tokio::time::Instant::now() >= d) {
5089            return Err(
5090                "the call's time ran out before the page could be probed (the app's UI thread is \
5091                 busy)"
5092                    .to_string(),
5093            );
5094        }
5095        let id = uuid::Uuid::new_v4().to_string();
5096        let (tx, rx) = tokio::sync::oneshot::channel();
5097        let slot = self.reserve_pending(&id, tx).await?;
5098        slot.bind_window(webview_label);
5099        let probe = crate::js_bridge::eval_probe_script(&id);
5100        let injected = {
5101            let _budget = deadline.map(|d| crate::bridge::CallBudget::until(d.into_std()));
5102            self.bridge.eval_webview(webview_label, &probe)
5103        };
5104        if let Err(e) = injected {
5105            return Err(format!("eval injection failed: {e}"));
5106        }
5107        let wait = answer_by.map_or(PROBE_ANSWER_WINDOW, |d| {
5108            d.saturating_duration_since(tokio::time::Instant::now())
5109                .min(PROBE_ANSWER_WINDOW)
5110        });
5111        if let Ok(Ok(raw)) = tokio::time::timeout(wait, rx).await {
5112            Ok(crate::js_bridge::probe_answer_nonce(&raw))
5113        } else {
5114            let label = webview_label.unwrap_or("default");
5115            Err(format!(
5116                "bridge not responding on window '{label}' — the window may be hidden, \
5117                 missing the victauri capability, or the JS bridge is not loaded (e.g. the page \
5118                 failed to load: a dev-server connection-refused or blank error page has no JS \
5119                 bridge — check the window with the `screenshot` tool, which works regardless)"
5120            ))
5121        }
5122    }
5123
5124    /// Whether window `label` has shown a page other than the one an eval was armed in since
5125    /// ready-signal `seen` — and if so, the error to report. A ready signal carrying the armed
5126    /// nonce is the eval's own page announcing itself late (under load that can take seconds);
5127    /// any other is confirmed by asking the page for its CURRENT nonce, because page script can
5128    /// send a ready signal itself and must not be able to abort the agent's calls with it.
5129    ///
5130    /// Only POSITIVE evidence aborts: the page answering the probe with a nonce other than the
5131    /// armed one (its bridge's nonce is frozen, so it cannot change without a new page — and a
5132    /// page answering with no nonce where one was armed has lost the bridge that armed the
5133    /// eval). A probe that fails (a busy UI thread, a full slot map) proves nothing, so the
5134    /// eval keeps waiting — its own timeout still bounds it — and the signal is re-checked on
5135    /// the next watch tick. Aborting on a failed probe let page script (a forged signal while
5136    /// the UI is busy) cut an agent's call short and invited a retry that ran code twice.
5137    /// With no armed nonce (a page without the Victauri bridge) only a page that now HAS a
5138    /// nonce is evidence of a change.
5139    ///
5140    /// Bounded by the call's `deadline`: once it has passed, no probe is started (the signal is
5141    /// left unconfirmed and the eval's own deadline ends the call).
5142    async fn page_replaced(
5143        &self,
5144        label: Option<&str>,
5145        seen: &mut u64,
5146        armed: Option<&str>,
5147        deadline: tokio::time::Instant,
5148    ) -> Option<String> {
5149        let label = label?;
5150        let load = self.state.page_loads.latest(label)?;
5151        if load.seq <= *seen {
5152            return None;
5153        }
5154        if tokio::time::Instant::now() >= deadline {
5155            return None;
5156        }
5157        let previously_seen = *seen;
5158        *seen = load.seq;
5159        if armed.is_some() && load.nonce.as_deref() == armed {
5160            return None;
5161        }
5162        // A probe that cannot finish by the deadline proves nothing here (see above), so its
5163        // answer wait is cut to the time left: confirming a reload never extends the call.
5164        match self
5165            .probe_bridge(Some(label), Some(deadline), Some(deadline))
5166            .await
5167        {
5168            Ok(current) if current.as_deref() != armed => Some(format!(
5169                "window '{label}' loaded a new page (a reload or navigation) while the call was \
5170                 in flight, so no result will arrive. The code may or may not have run before \
5171                 the reload — check the app's state before re-running it."
5172            )),
5173            Ok(_) => None,
5174            Err(e) => {
5175                tracing::debug!(
5176                    window = label,
5177                    "ready signal not confirmed ({e}); the eval keeps waiting"
5178                );
5179                // Unconfirmed: look at this signal again on the next watch tick.
5180                *seen = previously_seen;
5181                None
5182            }
5183        }
5184    }
5185
5186    /// Drain every window into the active recording one last time before `recording stop`,
5187    /// within [`FINAL_FLUSH_BUDGET`] in total. Returns the windows that could not be read in time
5188    /// (their not-yet-drained events are missing from the stopped session).
5189    async fn final_recording_flush(&self) -> Vec<String> {
5190        let Ok(labels) = self.bridge.try_list_window_labels() else {
5191            return vec!["(window list unavailable: UI thread busy)".to_string()];
5192        };
5193        let mut pending = labels.clone();
5194        let flushed = tokio::time::timeout(FINAL_FLUSH_BUDGET, async {
5195            for label in &labels {
5196                if crate::mcp::server::drain_window_into_recording(&self.state, &self.bridge, label)
5197                    .await
5198                    .is_some()
5199                {
5200                    pending.retain(|l| l != label);
5201                }
5202            }
5203        })
5204        .await;
5205        if flushed.is_err() {
5206            tracing::debug!("recording stop: final flush ran out of time");
5207        }
5208        pending
5209    }
5210
5211    async fn eval_with_return_timeout(
5212        &self,
5213        code: &str,
5214        webview_label: Option<&str>,
5215        timeout: std::time::Duration,
5216    ) -> Result<String, String> {
5217        self.eval_outcome(code, webview_label, timeout)
5218            .await
5219            .map_err(|f| f.message)
5220    }
5221
5222    /// Run `code` in the webview and wait for its outcome; a failure says whether the code ran.
5223    #[allow(clippy::too_many_lines)]
5224    async fn eval_outcome(
5225        &self,
5226        code: &str,
5227        webview_label: Option<&str>,
5228        timeout: std::time::Duration,
5229    ) -> Result<String, EvalFailure> {
5230        use EvalFailureKind::{Aborted, NotSent, Page};
5231
5232        // ONE deadline for the whole call. The probe, the injection and the parse check are
5233        // main-thread round trips; on a busy UI thread each could take up to ~10-20 s, and the
5234        // wait for the result used to start its own `timeout` only AFTER them, so a "30 s"
5235        // eval could run for two minutes. Every step below now draws on this one budget (a
5236        // closure the UI thread had not started when it ran out is abandoned — it never runs,
5237        // so "not sent" stays true; one that started is still waited for).
5238        //
5239        // Two floors keep a SHORT timeout from turning load into false failures: sending (the
5240        // liveness probe + injection) may use at least `MIN_SEND_WINDOW` — room for the probe's
5241        // full 2 s, since a probe cut short cannot tell a dead page from a busy one — and code
5242        // that was sent gets at least `MIN_RESULT_WINDOW` (or its whole timeout, if shorter) to
5243        // answer, counted from after the parse check. The bound, worst case: a probe dispatched
5244        // just before the send deadline still gets its 2 s answer window (then the call reports
5245        // NotSent), so a call ends within max(timeout, 2.5 s) + 2 s — plus the grace of a
5246        // main-thread step that had already started when time ran out.
5247        let started = tokio::time::Instant::now();
5248        let deadline = started + timeout;
5249        let send_deadline = deadline.max(started + MIN_SEND_WINDOW);
5250
5251        // The hard concurrency ceiling is enforced atomically at every reservation
5252        // (`reserve_pending`, used by both the probe and the real eval below) — NOT with a
5253        // separate early check, which races: concurrent callers would all pass a stale
5254        // `len()` read before any of them inserts. The probe is the first reservation, so a
5255        // saturated map is rejected fast (before any eval is injected) with the real "too
5256        // many concurrent" cause.
5257
5258        // Wait for the JS bridge ready signal (sent on bridge init) before
5259        // attempting evals.  For explicitly targeted windows the probe
5260        // mechanism is still used because the ready signal only proves that
5261        // *some* webview's bridge loaded — not necessarily the targeted one.
5262        if !self
5263            .state
5264            .bridge_ready
5265            .load(std::sync::atomic::Ordering::Acquire)
5266        {
5267            let notified = self.state.bridge_notify.notified();
5268            if !self
5269                .state
5270                .bridge_ready
5271                .load(std::sync::atomic::Ordering::Acquire)
5272            {
5273                // Leave the probe its full answer window: a page whose bridge never signals ready
5274                // (not loaded, or a window without the `victauri:default` capability) must still
5275                // be probed, so the agent gets that diagnosis rather than "out of time".
5276                let ready_wait = std::time::Duration::from_secs(5).min(
5277                    send_deadline
5278                        .saturating_duration_since(tokio::time::Instant::now())
5279                        .saturating_sub(PROBE_ANSWER_WINDOW),
5280                );
5281                let _ = tokio::time::timeout(ready_wait, notified).await;
5282            }
5283        }
5284
5285        // Subscribe before anything is sent AND check the current value: `subscribe()` marks it
5286        // seen, so a call started after the app's exit signal would otherwise wait out its whole
5287        // timeout for a change that has already happened.
5288        let mut shutdown = self.state.shutdown_tx.subscribe();
5289        if *shutdown.borrow() {
5290            return Err(EvalFailure::new(
5291                NotSent,
5292                "the app is shutting down, so the call was not sent",
5293            ));
5294        }
5295
5296        // Reserved sentinel key for the default (unlabeled) window — cannot
5297        // collide with a real label.
5298        let label_key =
5299            webview_label.map_or_else(|| "\u{1}__default__".to_string(), str::to_string);
5300
5301        // Ready signals from here on may come from a page that replaced the one this eval runs
5302        // in; an earlier one cannot (the probe below then answers from the new page).
5303        let mut loads_seen = self.state.page_loads.current_seq();
5304
5305        // Liveness probe before EVERY eval — on the DEFAULT window as well as
5306        // labeled ones. The probe is a tiny round-trip that returns in ~ms on a
5307        // healthy bridge and fails fast (~2s) on a dead/hung/reloading one, turning
5308        // a full-timeout hang (e.g. 30s) into an immediate, clear "bridge not
5309        // responding" error. This was the #1 live-4DA friction: a webview that
5310        // reloads mid-session (HMR) made the very next tool call hang the full
5311        // timeout, and the DEFAULT window — the most common target — was never
5312        // probed at all. Probing every call (not once-cached) is what guarantees
5313        // *zero* 30s hangs even across repeated reloads; the healthy-path cost is a
5314        // single sub-millisecond localhost round-trip, negligible against the value
5315        // of never stalling an agent into a CDP fallback. It also reports the nonce of the
5316        // page the eval is armed in, which is what tells a reload from a late ready signal.
5317        let prev_timed_out = self.timed_out_labels.lock().await.remove(&label_key);
5318        let armed_nonce = match self
5319            .probe_bridge(webview_label, Some(send_deadline), None)
5320            .await
5321        {
5322            Ok(nonce) => nonce,
5323            // The probe's own delivery was not picked up within the send window: a busy UI
5324            // thread, not a dead page or a failed injection of the agent's code.
5325            Err(e) if e.contains("(it will not run)") => {
5326                return Err(EvalFailure::new(
5327                    NotSent,
5328                    format!(
5329                        "eval ran out of time before the code was sent ({} elapsed, timeout {}) \
5330                         — the app's UI thread is busy (the liveness probe could not be \
5331                         delivered: {e}). The code did NOT run; retry when the app is \
5332                         responsive, or pass a longer timeout.",
5333                        format_timeout(started.elapsed()),
5334                        format_timeout(timeout)
5335                    ),
5336                ));
5337            }
5338            Err(e) => {
5339                return Err(EvalFailure::new(
5340                    NotSent,
5341                    if prev_timed_out {
5342                        format!(
5343                            "{e} (a previous eval on this window also timed out — the webview \
5344                             likely reloaded or the app stopped responding)"
5345                        )
5346                    } else {
5347                        e
5348                    },
5349                ));
5350            }
5351        };
5352
5353        let id = uuid::Uuid::new_v4().to_string();
5354        let (tx, rx) = tokio::sync::oneshot::channel();
5355        let slot = self
5356            .reserve_pending(&id, tx)
5357            .await
5358            .map_err(|e| EvalFailure::new(NotSent, e))?;
5359        // Only the target window may answer (a `None` label is narrowed once it has resolved).
5360        slot.bind_window(webview_label);
5361
5362        // Auto-prepend `return` so bare expressions produce a value — but ONLY
5363        // for single expressions. Multi-statement blocks (or code containing an
5364        // explicit `return`) are used as-is. Prepending `return` to a statement
5365        // block like `foo(); return bar()` would parse as `return foo();` and
5366        // silently discard everything after the first statement (issue: core
5367        // primitive returned wrong/undefined values for "do X, then return Y").
5368        // Prepend to the code with its LEADING comments stripped: `return // note\nexpr`
5369        // parses (ASI) as `return;` and silently yields undefined.
5370        let body = strip_leading_js_comments(code.trim());
5371        let code = if should_prepend_return(body) {
5372            format!("return {body}")
5373        } else {
5374            code.trim().to_string()
5375        };
5376
5377        // Fail fast on a SYNTAX error instead of hanging for the full timeout (audit /
5378        // red-team "malformed eval consumes the full 30s"). The user code is inlined into
5379        // the wrapper; if it has a parse error the WHOLE script fails to parse, so its callback
5380        // never fires. We cannot wrap the code in `new Function`/`AsyncFunction` to surface the
5381        // SyntaxError, because dynamic code generation is gated by the same `unsafe-eval` CSP
5382        // that blocks `eval()` — which is exactly why the bridge uses an inline async-IIFE in
5383        // the first place. Instead a check script (which always parses) is delivered right
5384        // AFTER the wrapper, to the same window: webview evals run in order, and a wrapper that
5385        // parsed has already marked itself begun, so "not begun" means it did not parse. There
5386        // is no timer to race: the check used to be a 750ms watchdog armed BEFORE the code, and
5387        // a busy main thread delaying the code past it reported a parse error for code that
5388        // then ran (an `invoke_command` retried on that ran twice).
5389        // The settle logic lives in the bridge's closure-private state (see `_evalBegin` /
5390        // `_evalCheck` / `_evalSettle` in js_bridge.rs): the page can neither enumerate pending
5391        // eval ids nor suppress results, each outcome is delivered at most once, and
5392        // serialization uses a `JSON.stringify` captured before any page script ran.
5393        // Out of time before the code is sent (a slow probe on a busy UI thread): send nothing —
5394        // the code never runs, and the agent is told exactly that.
5395        if tokio::time::Instant::now() >= send_deadline {
5396            return Err(EvalFailure::new(
5397                NotSent,
5398                format!(
5399                    "eval ran out of time before the code was sent ({} elapsed, timeout {}) — \
5400                     the app's UI thread is busy (the page answered the liveness probe late). \
5401                     The code did NOT run; retry when the app is responsive, or pass a longer \
5402                     timeout.",
5403                    format_timeout(started.elapsed()),
5404                    format_timeout(timeout)
5405                ),
5406            ));
5407        }
5408        let inject = crate::js_bridge::eval_wrapper_script(&id, &code);
5409        let target = {
5410            let _budget = crate::bridge::CallBudget::until(send_deadline.into_std());
5411            self.bridge.eval_webview_resolved(webview_label, &inject)
5412        }
5413        .map_err(|e| {
5414            // A delivery that STARTED on the main thread (then outlived its grace, or panicked)
5415            // may have run the code: never tell the agent it did not.
5416            if crate::bridge::closure_may_have_run(&e) {
5417                EvalFailure::new(
5418                    Aborted,
5419                    format!(
5420                        "eval delivery did not finish: {e}. The code may or may not have run — \
5421                         check the app's state before re-running it."
5422                    ),
5423                )
5424            } else if e.contains("(it will not run)") {
5425                // The UI thread did not pick the delivery up within the call's send window —
5426                // the same exhaustion as the check above, one step later: same diagnosis.
5427                EvalFailure::new(
5428                    NotSent,
5429                    format!(
5430                        "eval ran out of time before the code was sent ({} elapsed, timeout {}) \
5431                         — the app's UI thread is busy ({e}). The code did NOT run; retry when \
5432                         the app is responsive, or pass a longer timeout.",
5433                        format_timeout(started.elapsed()),
5434                        format_timeout(timeout)
5435                    ),
5436                )
5437            } else {
5438                EvalFailure::new(NotSent, format!("eval injection failed: {e}"))
5439            }
5440        })?;
5441        // The code is in the page now: it gets at least `MIN_RESULT_WINDOW` (or its whole
5442        // timeout, if shorter) to answer, even when sending ran close to the deadline.
5443        let result_floor = timeout.min(MIN_RESULT_WINDOW);
5444        let deadline = deadline.max(tokio::time::Instant::now() + result_floor);
5445        let deliver_to = if target.is_empty() {
5446            webview_label
5447        } else {
5448            slot.bind_window(Some(target.as_str()));
5449            Some(target.as_str())
5450        };
5451        let check = crate::js_bridge::eval_check_script(&id, armed_nonce.as_deref());
5452        // The check only runs when the page reported a nonce (see `eval_check_script`) and the
5453        // script reached it; only then can a timeout rule out a parse error.
5454        // Budgeted to the result deadline (the code is already in the page, so the check may use
5455        // the time the result has), and the result floor is re-applied AFTER it: the time the
5456        // check took on a busy UI thread must not eat the window the code's answer gets.
5457        let check_sent = {
5458            let _budget = crate::bridge::CallBudget::until(deadline.into_std());
5459            self.bridge.eval_webview(deliver_to, &check)
5460        };
5461        let deadline = deadline.max(tokio::time::Instant::now() + result_floor);
5462        let parse_check_armed = match check_sent {
5463            Ok(()) => armed_nonce.is_some(),
5464            Err(e) => {
5465                // Only the fast parse-error report is lost; the code itself was delivered.
5466                tracing::debug!("eval parse check not delivered: {e}");
5467                false
5468            }
5469        };
5470
5471        // While waiting, watch for the ways a call ends with NO callback ever coming: the
5472        // target window was destroyed, it loaded a new page, or the app began shutting down.
5473        // All are the EXPECTED outcome of code that closes its own window, navigates or quits
5474        // the app (e.g. invoking a `quit_app` command) — reporting them after the full timeout
5475        // as "an unresolved promise, an infinite loop…" misled agents into thinking the call
5476        // never ran.
5477        //
5478        // The window check runs in its own task: listing windows is a main-thread round trip
5479        // that can take up to ~2x its 10s dispatch timeout (about 20s) on a busy UI, and must neither stall this
5480        // wait nor push it past its deadline. A listing that FAILS (a busy or wedged UI) is not
5481        // evidence of anything — only a successful listing that lacks the window is.
5482        let watched: Option<String> = (!target.is_empty()).then_some(target);
5483        let mut liveness = tokio::time::interval_at(
5484            tokio::time::Instant::now() + EVAL_WINDOW_WATCH_INTERVAL,
5485            EVAL_WINDOW_WATCH_INTERVAL,
5486        );
5487        let mut check: Option<tokio::task::JoinHandle<Result<Vec<String>, String>>> = None;
5488        let armed = armed_nonce.as_deref();
5489        let mut rx = rx;
5490        // A reload that completed while the code was being delivered is already recorded.
5491        let outcome = if let Some(msg) = self
5492            .page_replaced(watched.as_deref(), &mut loads_seen, armed, deadline)
5493            .await
5494        {
5495            Err(Some(msg))
5496        } else {
5497            loop {
5498                tokio::select! {
5499                    // A result that has arrived always wins — over the deadline too, which can be
5500                    // due by the time this loop first runs (sending ran long on a busy UI thread).
5501                    biased;
5502                    r = &mut rx => break Ok(r),
5503                    () = self.state.page_loads.changed() => {
5504                        if let Some(msg) =
5505                            self.page_replaced(watched.as_deref(), &mut loads_seen, armed, deadline).await
5506                        {
5507                            break Err(Some(msg));
5508                        }
5509                    }
5510                    () = tokio::time::sleep_until(deadline) => break Err(None),
5511                    changed = shutdown.changed() => {
5512                        if changed.is_err() || *shutdown.borrow() {
5513                            break Err(Some(
5514                                "the app began shutting down while the call was in flight, so \
5515                                 no result will arrive. If the code/command quits or restarts the \
5516                                 app, this is the expected outcome and it most likely ran — do \
5517                                 not re-run it blindly."
5518                                    .to_string(),
5519                            ));
5520                        }
5521                    }
5522                    _ = liveness.tick(), if watched.is_some() && check.is_none() => {
5523                        // Backstop for a ready signal that arrived between select rounds.
5524                        if let Some(msg) =
5525                            self.page_replaced(watched.as_deref(), &mut loads_seen, armed, deadline).await
5526                        {
5527                            break Err(Some(msg));
5528                        }
5529                        let bridge = Arc::clone(&self.bridge);
5530                        check = Some(tokio::spawn(async move { bridge.try_list_window_labels() }));
5531                    }
5532                    listed = async { check.as_mut().expect("guarded by precondition").await },
5533                        if check.is_some() =>
5534                    {
5535                        check = None;
5536                        if let (Ok(Ok(labels)), Some(label)) = (listed, watched.as_deref())
5537                            && !labels.iter().any(|l| l == label)
5538                        {
5539                            break Err(Some(format!(
5540                                "window '{label}' was closed while the call was in flight, so no \
5541                                 result will arrive. If the code/command closes this window (or \
5542                                 quits the app), this is the expected outcome and it most likely \
5543                                 ran — do not re-run it blindly."
5544                            )));
5545                        }
5546                    }
5547                }
5548            }
5549        };
5550        if let Some(pending_check) = check {
5551            pending_check.abort();
5552        }
5553        let early = match outcome {
5554            Ok(r) => Ok(r),
5555            // The result may have landed while the page change was being confirmed.
5556            Err(Some(msg)) => match rx.try_recv() {
5557                Ok(raw) => Ok(Ok(raw)),
5558                Err(_) => return Err(EvalFailure::new(Aborted, msg)),
5559            },
5560            // ... or right at the deadline: a result that arrived is never reported as a timeout
5561            // (that invites a retry that runs a command twice).
5562            Err(None) => rx.try_recv().map(Ok).map_err(|_| ()),
5563        };
5564        match early {
5565            Ok(Ok(raw)) => {
5566                self.check_bridge_version_once();
5567                if raw.len() > MAX_EVAL_RESULT_LEN {
5568                    return Err(EvalFailure::new(
5569                        Page,
5570                        format!(
5571                            "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
5572                            raw.len()
5573                        ),
5574                    ));
5575                }
5576                unwrap_eval_envelope(raw)
5577            }
5578            Ok(Err(_)) => Err(EvalFailure::new(Aborted, "eval callback channel closed")),
5579            Err(()) => {
5580                // Mark this window so the NEXT eval does a fast liveness probe —
5581                // if the bridge is gone (reloaded/crashed) the next call fails in
5582                // ~2s instead of blocking the full timeout again.
5583                self.timed_out_labels.lock().await.insert(label_key.clone());
5584                let (began, parse_note) = if parse_check_armed {
5585                    (
5586                        "the code began executing but never resolved",
5587                        "(A syntax/parse error is reported immediately, so this is NOT a parse \
5588                         error.) Common causes",
5589                    )
5590                } else {
5591                    (
5592                        "no result arrived",
5593                        "(The fast syntax-error check could not run for this call, so a parse \
5594                         error could not be ruled out — check the code's syntax.) Other causes",
5595                    )
5596                };
5597                Err(EvalFailure::new(
5598                    Aborted,
5599                    format!(
5600                        "eval timed out after {} — {began}. {parse_note}: an unresolved \
5601                         promise, an infinite loop, an `await` on something that never settles, \
5602                         or the webview reloaded / the app stopped responding mid-eval. If the \
5603                         app may have navigated or crashed, retry (the next call fails fast if \
5604                         the bridge is gone).",
5605                        format_timeout(timeout)
5606                    ),
5607                ))
5608            }
5609        }
5610    }
5611
5612    #[cfg(feature = "sqlite")]
5613    async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
5614        let roots = self.db_roots();
5615
5616        let path = if let Some(p) = db_path {
5617            Self::resolve_existing_db_path(&roots, p)?
5618        } else {
5619            // Configured db_search_paths are EXCLUSIVE when set (don't fall back to the
5620            // OS app dirs that hold WebView internals); WebView/engine internal stores are
5621            // excluded and the largest real candidate wins (audit / red-team "wrong DB").
5622            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
5623                roots.clone()
5624            } else {
5625                self.state.db_search_paths.clone()
5626            };
5627            // A directory walk: off the server's async workers (see `app_info`).
5628            tokio::task::spawn_blocking(move || crate::database::select_app_database(&select_dirs))
5629                .await
5630                .map_err(|e| format!("database discovery failed: {e}"))??
5631        };
5632        let path_str = path
5633            .to_str()
5634            .ok_or_else(|| "invalid path encoding".to_string())?
5635            .to_string();
5636
5637        bounded::run_blocking_bounded(
5638            Some(&bounded::DB_SLOTS),
5639            "db health check",
5640            crate::database::DB_HEALTH_META_BUDGET
5641                + DB_HEALTH_COUNT_BUDGET
5642                + DB_HEALTH_CHECK_BUDGET
5643                + bounded::BLOCKING_DEADLINE_SLACK,
5644            move || {
5645                crate::database::db_health_report(
5646                    &path_str,
5647                    DB_HEALTH_COUNT_BUDGET,
5648                    DB_HEALTH_CHECK_BUDGET,
5649                )
5650            },
5651        )
5652        .await
5653    }
5654
5655    fn check_bridge_version_once(&self) {
5656        if self.bridge_checked.swap(true, Ordering::Relaxed) {
5657            return;
5658        }
5659        let handler = self.clone();
5660        tokio::spawn(async move {
5661            match handler
5662                .eval_with_return_timeout(
5663                    "window.__VICTAURI__?.version",
5664                    None,
5665                    std::time::Duration::from_secs(5),
5666                )
5667                .await
5668            {
5669                Ok(v) => {
5670                    let v = v.trim_matches('"');
5671                    if v == BRIDGE_VERSION {
5672                        tracing::debug!("Bridge version verified: {v}");
5673                    } else {
5674                        tracing::warn!(
5675                            "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
5676                        );
5677                    }
5678                }
5679                Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
5680            }
5681        });
5682    }
5683}
5684
5685const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
5686It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
5687(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
5688(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
5689\n\nBACKEND tools (direct Rust access, no webview needed): \
5690'app_info' (app config, directory paths, discovered databases, process info), \
5691'list_app_dir' (browse app data/config/log directories), \
5692'read_app_file' (read files from app directories), \
5693'query_db' (read-only SQLite queries with auto-discovery). \
5694\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
5695'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
5696capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
5697Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
5698capability/security auditing, database diagnostics, plugin state, child process enumeration, \
5699task tracking, and automatic Tauri event bus monitoring. \
5700'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
5701drops, and response corruption into Tauri commands at the Rust layer. \
5702'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
5703activity across IPC + DOM + console + network + window events into a coherent narrative. \
5704\n\nWEBVIEW tools: \
5705'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
5706'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
5707'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
5708\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
5709\n\nCOMPOUND tools with an 'action' parameter: \
5710'window' (get_state, list, manage, resize, move_to, set_title), \
5711'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
5712set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
5713get_events, events_between, get_replay, export, import, replay), \
5714'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
5715\n\nOTHER: verify_state, wait_for (incl. 'expression'/'event' conditions to await \
5716async backend work to true completion), assert_semantic, resolve_command, \
5717app_state (app-defined backend state probes), \
5718get_memory_stats, get_plugin_info, get_diagnostics.";
5719
5720impl ServerHandler for VictauriMcpHandler {
5721    fn get_info(&self) -> InitializeResult {
5722        // NOTE: we advertise `resources` (read) but NOT `resources.subscribe`. A real
5723        // server-initiated `notifications/resources/updated` push was never implemented
5724        // (subscribe/unsubscribe only record intent in memory; nothing emits updates), and
5725        // the default stateless transport has no SSE channel to push over anyway. Advertising
5726        // a subscribe capability we cannot honour misleads clients — read resources on demand.
5727        InitializeResult::new(
5728            ServerCapabilities::builder()
5729                .enable_tools()
5730                .enable_resources()
5731                .build(),
5732        )
5733        .with_instructions(SERVER_INSTRUCTIONS)
5734    }
5735
5736    async fn list_tools(
5737        &self,
5738        _request: Option<PaginatedRequestParams>,
5739        _context: RequestContext<RoleServer>,
5740    ) -> Result<ListToolsResult, ErrorData> {
5741        let all_tools = Self::tool_router().list_all();
5742        let filtered: Vec<Tool> = all_tools
5743            .into_iter()
5744            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
5745            .collect();
5746        // SEP-2549 cache hints: the tool list is fixed for the process lifetime (the
5747        // privacy config that filters it is set at plugin init), so clients may cache
5748        // it. `Private` because the list depends on this instance's privacy profile.
5749        // Legacy (< 2026-07-28) peers never see these fields — rmcp strips them.
5750        let mut result = ListToolsResult::with_all_items(filtered);
5751        result.ttl_ms = Some(LIST_RESULT_TTL_MS);
5752        result.cache_scope = Some(CacheScope::Private);
5753        Ok(result)
5754    }
5755
5756    async fn call_tool(
5757        &self,
5758        request: CallToolRequestParams,
5759        context: RequestContext<RoleServer>,
5760    ) -> Result<CallToolResponse, ErrorData> {
5761        let tool_name: String = request.name.as_ref().to_owned();
5762        // Centralized authorization: gate on the canonical `tool.action` capability
5763        // resolved from the call arguments, matching the REST path in `execute_tool`.
5764        let args_value = serde_json::Value::Object(request.arguments.clone().unwrap_or_default());
5765        // A disabled tool reports "disabled" whatever its arguments look like.
5766        if self.state.privacy.disabled_tools.contains(&tool_name) {
5767            return Ok(tool_disabled(&tool_name).into());
5768        }
5769        let capability = authz::resolve_capability(&tool_name, &args_value)
5770            .map_err(|msg| ErrorData::invalid_params(msg, None))?;
5771        if !self.state.privacy.is_call_allowed(&tool_name, &capability) {
5772            tracing::debug!(tool = %tool_name, capability = %capability, "tool call blocked by privacy config");
5773            return Ok(tool_disabled(&capability).into());
5774        }
5775        self.state
5776            .tool_invocations
5777            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5778        let start = std::time::Instant::now();
5779        tracing::debug!(tool = %tool_name, "tool invocation started");
5780        let ctx = ToolCallContext::new(self, request, context);
5781        // Same panic boundary as the REST path: without it a panicking handler left the MCP
5782        // request unanswered until the client's own timeout.
5783        let response = bounded::CatchUnwind::new(Self::tool_router().call(ctx))
5784            .await
5785            .unwrap_or_else(|panic| {
5786                tracing::error!(tool = %tool_name, "tool handler panicked: {panic}");
5787                Ok(tool_panicked(&tool_name, &panic).into())
5788            });
5789        let elapsed = start.elapsed();
5790        tracing::debug!(
5791            tool = %tool_name,
5792            elapsed_ms = elapsed.as_millis() as u64,
5793            is_error = response.as_ref().map_or(true, |r| match r {
5794                CallToolResponse::Complete(r) => r.is_error.unwrap_or(false),
5795                _ => false,
5796            }),
5797            "tool invocation completed"
5798        );
5799
5800        // Centralized output redaction: apply to all text content so no
5801        // individual tool can accidentally leak secrets. Victauri tools always
5802        // complete in one round trip, so only the `Complete` variant carries
5803        // output; MRTR intermediates (input-required / task) pass through.
5804        if self.state.privacy.redaction_enabled {
5805            response.map(|resp| match resp {
5806                CallToolResponse::Complete(mut r) => {
5807                    for item in &mut r.content {
5808                        if let ContentBlock::Text(tc) = item {
5809                            tc.text = self.state.privacy.redact_output(&tc.text);
5810                        }
5811                    }
5812                    CallToolResponse::Complete(r)
5813                }
5814                other => other,
5815            })
5816        } else {
5817            response
5818        }
5819    }
5820
5821    fn get_tool(&self, name: &str) -> Option<Tool> {
5822        if !self.state.privacy.is_tool_enabled(name) {
5823            return None;
5824        }
5825        Self::tool_router().get(name).cloned()
5826    }
5827
5828    async fn list_resources(
5829        &self,
5830        _request: Option<PaginatedRequestParams>,
5831        _context: RequestContext<RoleServer>,
5832    ) -> Result<ListResourcesResult, ErrorData> {
5833        // SEP-2549 cache hints: the resource *list* (not the contents) is static for
5834        // the process lifetime, so clients may cache it (same rationale as list_tools).
5835        let mut result = ListResourcesResult::with_all_items(vec![
5836            Resource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
5837                .with_description(
5838                    "Live IPC call log — all commands invoked between frontend and backend",
5839                )
5840                .with_mime_type("application/json"),
5841            Resource::new(RESOURCE_URI_WINDOWS, "windows")
5842                .with_description(
5843                    "Current state of all Tauri windows — position, size, visibility, focus",
5844                )
5845                .with_mime_type("application/json"),
5846            Resource::new(RESOURCE_URI_STATE, "state")
5847                .with_description(
5848                    "Victauri plugin state — event count, registered commands, memory stats",
5849                )
5850                .with_mime_type("application/json"),
5851        ]);
5852        result.ttl_ms = Some(LIST_RESULT_TTL_MS);
5853        result.cache_scope = Some(CacheScope::Private);
5854        Ok(result)
5855    }
5856
5857    async fn read_resource(
5858        &self,
5859        request: ReadResourceRequestParams,
5860        _context: RequestContext<RoleServer>,
5861    ) -> Result<ReadResourceResponse, ErrorData> {
5862        let uri = &request.uri;
5863        // Resources bypass the tool dispatcher, so they must apply the same privacy
5864        // gate themselves (audit B1): a strict profile that blocks log/window reads
5865        // as tools must not be able to read the same data via a resource.
5866        if !resource_allowed(&self.state.privacy, uri.as_str()) {
5867            return Err(ErrorData::invalid_request(
5868                format!("resource {uri} is not permitted by the current privacy configuration"),
5869                None,
5870            ));
5871        }
5872        let json = match uri.as_str() {
5873            RESOURCE_URI_IPC_LOG => {
5874                // Use the body-free, capped projection — NOT the full body-carrying
5875                // getIpcLog(). On a busy app the full log blows the eval result cap, the
5876                // eval fails, and we silently fall back to the Rust event_log (which is
5877                // itself default-window-drained) — serving a subset that looks complete.
5878                // trimmed_log_js bounds entries + truncates oversized fields so the
5879                // resource stays correct under load. (Matches the `logs ipc` tool.)
5880                let code = trimmed_log_js(
5881                    &format!("window.__VICTAURI__?.getIpcLog({DEFAULT_LOG_LIMIT})"),
5882                    DEFAULT_LOG_LIMIT,
5883                );
5884                if let Ok(json) = self.eval_with_return(&code, None).await {
5885                    json
5886                } else {
5887                    let calls = self.state.event_log.ipc_calls();
5888                    serde_json::to_string_pretty(&calls)
5889                        .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
5890                }
5891            }
5892            RESOURCE_URI_WINDOWS => {
5893                let states = self
5894                    .bridge
5895                    .try_get_window_states(None)
5896                    .map_err(|e| ErrorData::internal_error(ui_busy(&e), None))?;
5897                serde_json::to_string_pretty(&states)
5898                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
5899            }
5900            RESOURCE_URI_STATE => {
5901                let state_json = serde_json::json!({
5902                    "events_captured": self.state.event_log.len(),
5903                    "commands_registered": self.state.registry.count(),
5904                    "memory": crate::memory::current_stats(),
5905                    "port": self.state.port.load(Ordering::Relaxed),
5906                });
5907                serde_json::to_string_pretty(&state_json)
5908                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
5909            }
5910            _ => {
5911                return Err(ErrorData::resource_not_found(
5912                    format!("unknown resource: {uri}"),
5913                    None,
5914                ));
5915            }
5916        };
5917
5918        let json = if self.state.privacy.redaction_enabled {
5919            self.state.privacy.redact_output(&json)
5920        } else {
5921            json
5922        };
5923
5924        Ok(ReadResourceResult::new(vec![ResourceContents::text(json, uri)]).into())
5925    }
5926
5927    // `resources/subscribe` is legacy-protocol-only under MCP 2026-07-28 (replaced by
5928    // `subscriptions/listen`). Victauri intentionally keeps the legacy handlers: the
5929    // subscribe capability is not advertised (see `get_info`), no update push exists,
5930    // and legacy clients that call anyway get the same recorded-intent behavior as
5931    // before. `allow(deprecated)` because rmcp 3.x marks the trait methods deprecated.
5932    #[allow(deprecated)]
5933    async fn subscribe(
5934        &self,
5935        request: SubscribeRequestParams,
5936        _context: RequestContext<RoleServer>,
5937    ) -> Result<(), ErrorData> {
5938        let uri = &request.uri;
5939        // Same privacy gate as read_resource (audit B1) — don't let a blocked
5940        // resource be subscribed to for push updates.
5941        if !resource_allowed(&self.state.privacy, uri.as_str()) {
5942            return Err(ErrorData::invalid_request(
5943                format!("resource {uri} is not permitted by the current privacy configuration"),
5944                None,
5945            ));
5946        }
5947        match uri.as_str() {
5948            RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
5949                self.subscriptions.lock().await.insert(uri.clone());
5950                tracing::info!("Client subscribed to resource: {uri}");
5951                Ok(())
5952            }
5953            _ => Err(ErrorData::resource_not_found(
5954                format!("unknown resource: {uri}"),
5955                None,
5956            )),
5957        }
5958    }
5959
5960    #[allow(deprecated)]
5961    async fn unsubscribe(
5962        &self,
5963        request: UnsubscribeRequestParams,
5964        _context: RequestContext<RoleServer>,
5965    ) -> Result<(), ErrorData> {
5966        self.subscriptions.lock().await.remove(&request.uri);
5967        tracing::info!("Client unsubscribed from resource: {}", request.uri);
5968        Ok(())
5969    }
5970}
5971
5972/// JS `function trimField(v)` (with its `MB` bound): a string over [`MAX_LOG_FIELD_BYTES`]
5973/// UTF-16 units is cut with a marker, an object whose JSON is larger becomes a size marker.
5974/// The cut never falls between the halves of a surrogate pair — a lone surrogate serializes
5975/// to JSON that `serde_json` rejects, which failed the whole log read.
5976fn trim_field_js() -> String {
5977    let mb = MAX_LOG_FIELD_BYTES;
5978    format!(
5979        r"var MB = {mb};
5980            function trimField(v) {{
5981                if (typeof v === 'string') {{
5982                    if (v.length <= MB) return v;
5983                    var end = MB, c = v.charCodeAt(end - 1);
5984                    if (c >= 0xD800 && c <= 0xDBFF) end--;
5985                    return v.slice(0, end) + '…[+' + (v.length - end) + ' bytes truncated]';
5986                }}
5987                if (v && typeof v === 'object') {{
5988                    var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }}
5989                    if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }}
5990                }}
5991                return v;
5992            }}"
5993    )
5994}
5995
5996/// JS for `check_ipc_integrity`. Classifies the calls from the body-free IPC view and fetches
5997/// full entries (args + result) only for the <= 20 stale and <= 20 errored calls it lists:
5998/// deep-copying every retained body just to count statuses froze the UI thread (R5-JS1).
5999#[doc(hidden)]
6000#[must_use]
6001pub fn ipc_integrity_js(threshold_ms: i64) -> String {
6002    format!(
6003        r"return (function() {{
6004                var V = window.__VICTAURI__;
6005                var log = V?.getIpcLog(0, {{ bodies: false }}) || [];
6006                var now = Date.now();
6007                var threshold = {threshold_ms};
6008                var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
6009                var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
6010                var errored = log.filter(function(c) {{ return c.status === 'error'; }});
6011                var netCount = (V?.getNetworkLog(null, 0, {{ bodies: false }}) || []).length;
6012                var capture = (V && V.getIpcCaptureStatus) ? V.getIpcCaptureStatus() : null;
6013                var warning = null;
6014                if (capture && (capture.capture === 'none' || capture.capture === 'requests_only')) {{
6015                    warning = capture.note;
6016                }} else if (log.length === 0 && netCount > 5) {{
6017                    warning = 'Zero IPC calls captured but ' + netCount + ' network requests observed. IPC capture may not be working — check ipc_capture / get_diagnostics.';
6018                }}
6019                function withBodies(list) {{
6020                    list = list.slice(0, 20);
6021                    if (!list.length) return list;
6022                    var got = V.getIpcLog(0, {{ ids: list.map(function(c) {{ return c.id; }}) }}) || [];
6023                    var byId = {{}};
6024                    for (var i = 0; i < got.length; i++) byId[got[i].id] = got[i];
6025                    return list.map(function(c) {{ return byId[c.id] || c; }});
6026                }}
6027                // INTEGRITY = round-trip soundness: no stuck/stale (never-returned) calls.
6028                // A command that completed with an Err is a HEALTHY round-trip (it returned)
6029                // — every real app exercises error paths, so counting those as 'unhealthy'
6030                // would cry wolf. The error_count/errored_calls surface them for visibility,
6031                // but only stale calls flip `healthy`.
6032                return {{
6033                    healthy: stale.length === 0,
6034                    total_calls: log.length,
6035                    pending_count: pending.length,
6036                    stale_count: stale.length,
6037                    error_count: errored.length,
6038                    stale_calls: withBodies(stale),
6039                    errored_calls: withBodies(errored),
6040                    warning: warning,
6041                    ipc_capture: capture
6042                }};
6043            }})()"
6044    )
6045}
6046
6047/// JS for `logs slow_ipc`: ranks the calls from the body-free IPC view, then fetches full
6048/// (field-trimmed) entries only for the `limit` slowest it returns (R5-JS1).
6049#[doc(hidden)]
6050#[must_use]
6051pub fn slow_ipc_js(threshold_ms: u64, limit: usize) -> String {
6052    let trim_field = trim_field_js();
6053    format!(
6054        r"return (function() {{
6055                {trim_field}
6056                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; }}
6057                var V = window.__VICTAURI__;
6058                var log = V?.getIpcLog(0, {{ bodies: false }}) || [];
6059                var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold_ms}; }});
6060                slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
6061                var top = slow.slice(0, {limit});
6062                if (top.length) {{
6063                    var got = V.getIpcLog(0, {{ ids: top.map(function(c) {{ return c.id; }}) }}) || [];
6064                    var byId = {{}};
6065                    for (var i = 0; i < got.length; i++) byId[got[i].id] = got[i];
6066                    top = top.map(function(c) {{ return byId[c.id] || c; }});
6067                }}
6068                return {{ threshold_ms: {threshold_ms}, count: top.length, calls: top.map(trimEntry) }};
6069            }})()",
6070    )
6071}
6072
6073/// Reads the bridge's IPC capture status (`null` on a bridge without it).
6074const IPC_CAPTURE_STATUS_JS: &str = "return (window.__VICTAURI__ && typeof window.__VICTAURI__.getIpcCaptureStatus === 'function') ? window.__VICTAURI__.getIpcCaptureStatus() : null";
6075
6076/// Build a JS expression that takes an array of log entries (`source_expr`),
6077/// keeps at most `limit` of the most recent, and truncates any per-entry field
6078/// larger than [`MAX_LOG_FIELD_BYTES`]. This keeps IPC/network log results under
6079/// the eval size cap on busy apps where individual entries carry large bodies.
6080///
6081/// The returned code is a complete `return (...)` statement.
6082#[doc(hidden)]
6083#[must_use]
6084pub fn trimmed_log_js(source_expr: &str, limit: usize) -> String {
6085    let trim_field = trim_field_js();
6086    format!(
6087        r"return (function() {{
6088            {trim_field}
6089            function trimEntry(e) {{
6090                if (e == null || typeof e !== 'object') return e;
6091                var out = Array.isArray(e) ? [] : {{}};
6092                for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) out[k] = trimField(e[k]); }}
6093                return out;
6094            }}
6095            var arr = {source_expr} || [];
6096            if (arr.length > {limit}) arr = arr.slice(arr.length - {limit}); // not slice(-0): all
6097            return arr.map(trimEntry);
6098        }})()"
6099    )
6100}
6101
6102/// Wall-clock epoch milliseconds: the floor a new recording epoch's drain reads from. Taken
6103/// BEFORE the recording starts, so nothing logged after the start falls below it. (An imported
6104/// session's old start time used to pull in the page's whole history.)
6105fn now_ms() -> f64 {
6106    #[allow(clippy::cast_precision_loss)]
6107    let ms = chrono::Utc::now().timestamp_millis() as f64;
6108    ms
6109}
6110
6111/// Unwrap the `{"__victauri_ok": <val>, "__victauri_type": <t>}` (or
6112/// `{"__victauri_err": <msg>}`) envelope produced by the eval bridge into the
6113/// value/error string returned to callers.
6114///
6115/// Parsing uses `serde_json`'s default recursion limit (it is intentionally NOT
6116/// disabled — an unbounded recursive parse of a pathologically deep result
6117/// overflows the worker thread stack and crashes the host). When the parse
6118/// fails because the value is too deeply nested, the envelope is stripped by
6119/// string slicing (no recursion) so the actual value is still returned rather
6120/// than leaking the raw envelope string.
6121fn unwrap_eval_envelope(raw: String) -> Result<String, EvalFailure> {
6122    // Page JSON: sanitize lone surrogates first, or one truncated emoji in a result sends it
6123    // down the raw-string fallback below (and every consumer's own parse of it fails too).
6124    if let Ok(envelope) = page_json::parse_page_json::<serde_json::Value>(&raw) {
6125        if let Some(why) = envelope.get("__victauri_not_run") {
6126            return Err(EvalFailure::new(
6127                EvalFailureKind::NotSent,
6128                format!(
6129                    "{PARSE_ERROR_PREFIX} {}",
6130                    why.as_str().unwrap_or("the code did not begin executing")
6131                ),
6132            ));
6133        }
6134        if let Some(err) = envelope.get("__victauri_err") {
6135            return Err(EvalFailure::new(
6136                EvalFailureKind::Page,
6137                format!(
6138                    "JavaScript error: {}",
6139                    err.as_str().unwrap_or("unknown error")
6140                ),
6141            ));
6142        }
6143        if let Some(why) = envelope.get("__victauri_unserializable") {
6144            return Err(EvalFailure::new(
6145                EvalFailureKind::Page,
6146                format!(
6147                    "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.",
6148                    why.as_str().unwrap_or("unknown reason")
6149                ),
6150            ));
6151        }
6152        if envelope.get("__victauri_ok").is_some() {
6153            let js_type = envelope
6154                .get("__victauri_type")
6155                .and_then(|t| t.as_str())
6156                .unwrap_or("value");
6157            return match js_type {
6158                "undefined" => Ok("undefined".to_string()),
6159                "null" => Ok("null".to_string()),
6160                _ => Ok(serde_json::to_string(&envelope["__victauri_ok"])
6161                    .unwrap_or_else(|_| "null".to_string())),
6162            };
6163        }
6164    }
6165    // Fallback for results too deeply nested for the recursion-limited parser. `rfind` is
6166    // correct here: the wrapper appends `,"__victauri_type":"<type>"}` AFTER the entire payload,
6167    // so the real delimiter is structurally the LAST occurrence — a nested object key of the same
6168    // name appears earlier, and inside a string payload the quotes are escaped (`\"`), so neither
6169    // can be the last match. A hostile page therefore can't shift the slice boundary.
6170    if let Some(after) = raw.strip_prefix(r#"{"__victauri_ok":"#)
6171        && let Some(idx) = after.rfind(r#","__victauri_type":"#)
6172    {
6173        return Ok(after[..idx].to_string());
6174    }
6175    if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) {
6176        let msg = after.trim_end_matches('}').trim_matches('"');
6177        return Err(EvalFailure::new(
6178            EvalFailureKind::Page,
6179            format!("JavaScript error: {msg}"),
6180        ));
6181    }
6182    Ok(raw)
6183}
6184
6185/// The eval code for a caller-supplied EXPRESSION (`verify_state`, `assert_semantic`,
6186/// `wait_for` expression): code that returns the expression's value.
6187///
6188/// These used to be wrapped as `return (<expr>)`, so a trailing `;` or a trailing `// comment`
6189/// (which swallowed the closing paren) turned a valid expression into a parse error. A single
6190/// expression is now handed to the eval engine as-is: its string/comment-aware `return`
6191/// auto-prepend already accepts a trailing `;`, trailing comments and multi-line expressions,
6192/// and the wrapper ends the code with a newline. Anything the engine would NOT wrap (an object
6193/// literal `{a: 1}.a`, which reads as a block) keeps the parenthesized form, with trailing `;`s
6194/// dropped and the expression on lines of its own so a trailing line comment cannot swallow the
6195/// closing paren. Multi-statement code stays a syntax error (these tools take an expression).
6196fn expression_eval_code(expr: &str) -> String {
6197    if should_prepend_return(expr) {
6198        return expr.to_string();
6199    }
6200    let body = expr.trim_end_matches(|c: char| c == ';' || is_js_space(c));
6201    format!("return (\n{body}\n);")
6202}
6203
6204/// Parse the value of an expression evaluated by [`expression_eval_code`]. `undefined` (which
6205/// the engine reports as the bare text `undefined`) is read as `null`: JSON has no `undefined`,
6206/// and `null` is what JavaScript's `x == null` groups it with — so `exists` is false and `falsy`
6207/// is true for it instead of the call failing as "not valid JSON".
6208fn parse_expression_value(raw: &str) -> Result<serde_json::Value, serde_json::Error> {
6209    if raw == "undefined" {
6210        return Ok(serde_json::Value::Null);
6211    }
6212    serde_json::from_str(raw)
6213}
6214
6215/// The failure a page action reported, as tool-error text: `{ok: false, …}`, or an object with
6216/// a top-level `error` string and no `ok: true`. Anything else (arrays with per-item errors, an
6217/// audit whose findings contain an `error` field) is a result. The page's recovery hint is kept
6218/// in the same `[hint: …]` form every tool error uses; it is page-controlled, so only a plain
6219/// `UPPER_SNAKE` word is carried over.
6220fn page_action_error(result: &str) -> Option<String> {
6221    let value: serde_json::Value = page_json::parse_page_json(result).ok()?;
6222    let obj = value.as_object()?;
6223    let ok = obj.get("ok").and_then(serde_json::Value::as_bool);
6224    let error = obj.get("error").and_then(serde_json::Value::as_str);
6225    if ok == Some(true) || (ok.is_none() && error.is_none()) {
6226        return None;
6227    }
6228    let mut message = error.map_or_else(
6229        || format!("the page reported failure: {value}"),
6230        str::to_string,
6231    );
6232    if let Some(hint) = obj.get("hint").and_then(serde_json::Value::as_str)
6233        && !hint.is_empty()
6234        && hint.len() <= 32
6235        && hint.bytes().all(|b| b.is_ascii_uppercase() || b == b'_')
6236    {
6237        message.push_str(&format!("\n\n[hint: {hint}]"));
6238    }
6239    Some(message)
6240}
6241
6242/// Statement keywords where a leading `return` would be a syntax error. Matched as whole words
6243/// (followed by any non-identifier byte — `if\t(`, `const\n`, `function*` — or the end).
6244const STMT_KEYWORDS: &[&str] = &[
6245    "return", "if", "for", "while", "switch", "try", "const", "let", "var", "function", "class",
6246    "throw", "do", "debugger", "with",
6247];
6248
6249/// `code` starts with the whole word `word` (not merely a longer identifier sharing its prefix).
6250fn starts_with_word(code: &str, word: &str) -> bool {
6251    code.starts_with(word)
6252        && code[word.len()..]
6253            .chars()
6254            .next()
6255            .is_none_or(|c| !is_js_ident_char(c))
6256}
6257
6258/// Does `code` begin with a statement (not an expression) — a statement keyword, a block, an
6259/// `async function` declaration, or a label (`outer: for …`)? Prepending `return` to any of
6260/// these is a syntax error or changes its meaning.
6261fn starts_with_statement(code: &str) -> bool {
6262    if code.starts_with('{') || STMT_KEYWORDS.iter().any(|k| starts_with_word(code, k)) {
6263        return true;
6264    }
6265    if starts_with_word(code, "async") && starts_with_word(code[5..].trim_start(), "function") {
6266        return true;
6267    }
6268    // A label: an identifier followed (after optional whitespace) by a single `:`.
6269    let ident_len: usize = code
6270        .chars()
6271        .take_while(|&c| is_js_ident_char(c))
6272        .map(char::len_utf8)
6273        .sum();
6274    ident_len > 0
6275        && !code.as_bytes()[0].is_ascii_digit()
6276        && code[ident_len..].trim_start().starts_with(':')
6277}
6278
6279/// Blocking: open `path`, refuse it unless the OPENED file is a regular file (the handler's
6280/// earlier checks ran on the path, which can be swapped for a FIFO or device before the open),
6281/// then read at most `max_bytes + 1` bytes (audit B7: never the whole file; the `+1` detects
6282/// truncation). Returns the bytes, the file's size and its modification time (Unix seconds).
6283pub(crate) fn read_regular_file(
6284    path: &std::path::Path,
6285    max_bytes: usize,
6286) -> Result<(Vec<u8>, usize, Option<u64>), String> {
6287    use std::io::Read;
6288    let f = std::fs::File::open(path).map_err(|e| e.to_string())?;
6289    let metadata = f.metadata().map_err(|e| e.to_string())?;
6290    if !metadata.is_file() {
6291        return Err("not a regular file".to_string());
6292    }
6293    let modified = metadata.modified().ok().map(|t| {
6294        t.duration_since(std::time::SystemTime::UNIX_EPOCH)
6295            .unwrap_or_default()
6296            .as_secs()
6297    });
6298    let mut buf = Vec::new();
6299    f.take(max_bytes as u64 + 1)
6300        .read_to_end(&mut buf)
6301        .map_err(|e| e.to_string())?;
6302    let size = usize::try_from(metadata.len()).unwrap_or(usize::MAX);
6303    Ok((buf, size, modified))
6304}
6305
6306/// Resolve `.` and `..` components without touching the filesystem.
6307#[cfg(feature = "sqlite")]
6308fn lexically_normalize(path: &std::path::Path) -> std::path::PathBuf {
6309    let mut out = std::path::PathBuf::new();
6310    for component in path.components() {
6311        match component {
6312            std::path::Component::CurDir => {}
6313            std::path::Component::ParentDir => {
6314                out.pop();
6315            }
6316            other => out.push(other),
6317        }
6318    }
6319    out
6320}
6321
6322/// A line ending in one of these continues onto the next line (no ASI).
6323const ASI_CONTINUES_AFTER: &[u8] = b"+-*/%&|^!=<>?:,.([{~";
6324/// A line starting with one of these continues the previous line (no ASI) — including `(`,
6325/// `[` and a template literal, which JavaScript itself treats as a continuation.
6326const ASI_CONTINUES_BEFORE: &[u8] = b".?)]}+-*/%&|^=<>,:([`";
6327
6328/// Length of the JavaScript line terminator at byte `i` — LF, CR, CRLF (2), or U+2028 /
6329/// U+2029 (3) — or 0 when there is none there.
6330fn line_terminator_len(bytes: &[u8], i: usize) -> usize {
6331    match bytes.get(i) {
6332        Some(b'\n') => 1,
6333        Some(b'\r') => 1 + usize::from(bytes.get(i + 1) == Some(&b'\n')),
6334        Some(0xE2)
6335            if bytes.get(i + 1) == Some(&0x80) && matches!(bytes.get(i + 2), Some(0xA8 | 0xA9)) =>
6336        {
6337            3
6338        }
6339        _ => 0,
6340    }
6341}
6342
6343/// Index of the first line terminator at or after byte `from`, if any.
6344fn find_line_terminator(bytes: &[u8], from: usize) -> Option<usize> {
6345    (from..bytes.len()).find(|&i| line_terminator_len(bytes, i) > 0)
6346}
6347
6348/// `code` after the rest of its current line (and that line's terminator); `""` if none.
6349fn after_line(code: &str) -> &str {
6350    let bytes = code.as_bytes();
6351    find_line_terminator(bytes, 0).map_or("", |n| &code[n + line_terminator_len(bytes, n)..])
6352}
6353
6354/// Strip leading whitespace and comments: `//`, `/* */`, and the HTML-like `<!--` / `-->`
6355/// line comments (the code starts a line). Every JavaScript line terminator ends a line
6356/// comment — CR and U+2028/U+2029 as well as LF.
6357fn strip_leading_js_comments(mut code: &str) -> &str {
6358    loop {
6359        code = code.trim_start();
6360        if let Some(rest) = code
6361            .strip_prefix("//")
6362            .or_else(|| code.strip_prefix("<!--"))
6363            .or_else(|| code.strip_prefix("-->"))
6364        {
6365            code = after_line(rest);
6366        } else if let Some(rest) = code.strip_prefix("/*") {
6367            code = rest.find("*/").map_or("", |n| &rest[n + 2..]);
6368        } else {
6369            return code;
6370        }
6371    }
6372}
6373
6374/// What opened a bracket, for [`should_prepend_return`]: a `/` right after the `)` of an
6375/// `if`/`while`/`for`/`with` head starts a regex (the statement body), not a division, and `of`
6376/// is a keyword only directly inside a `for (…)` head.
6377#[derive(PartialEq, Clone, Copy)]
6378enum Bracket {
6379    Plain,
6380    ControlHead,
6381    ForHead,
6382}
6383
6384/// String/template/comment scan state for [`should_prepend_return`].
6385#[derive(PartialEq, Clone, Copy)]
6386enum ScanState {
6387    Code,
6388    SingleQuote,
6389    DoubleQuote,
6390    Template,
6391}
6392
6393/// Decide whether to wrap `code` with a leading `return`.
6394///
6395/// Only a single bare expression should get `return` prepended. Code that is a
6396/// multi-statement block, contains an explicit top-level `return`, or starts
6397/// with a statement keyword is used as-is — prepending `return` to such code
6398/// would execute only the first statement and silently discard the rest.
6399///
6400/// The scan is string/template/comment-aware and only treats a `;` or an
6401/// explicit `return` token as significant when it occurs at bracket depth 0
6402/// outside of any string, template literal, or comment.
6403fn should_prepend_return(code: &str) -> bool {
6404    use ScanState::{Code, DoubleQuote, SingleQuote, Template};
6405
6406    let code = strip_leading_js_comments(code.trim());
6407    if code.is_empty() || starts_with_statement(code) {
6408        return false;
6409    }
6410
6411    let bytes = code.as_bytes();
6412    let mut i = 0;
6413    let mut depth: i32 = 0;
6414    let mut state = ScanState::Code;
6415    // Depths at which a template literal's `${` substitution opened: the matching `}` resumes
6416    // the template (else a backtick inside `${'`'}` was read as the template's end).
6417    let mut template_depths: Vec<i32> = Vec::new();
6418    // Only whitespace since the last line terminator (an HTML-like `-->` comment position).
6419    let mut at_line_start = true;
6420    // Every open bracket (and `${`) and what opened it; the index of the last `)` that closed
6421    // an `if`/`while`/`for`/`with` head.
6422    let mut brackets: Vec<Bracket> = Vec::new();
6423    let mut control_head_closed_at: Option<usize> = None;
6424
6425    // Is there a top-level `return` token starting at byte `i` (word-bounded, and not a
6426    // property name such as `obj.return`)?
6427    let is_return_token = |i: usize| -> bool {
6428        let prev_ok = code[..i]
6429            .chars()
6430            .next_back()
6431            .is_none_or(|c| !is_js_ident_char(c));
6432        prev_ok
6433            && code[i..].starts_with("return")
6434            && code[i + 6..]
6435                .chars()
6436                .next()
6437                .is_none_or(|c| !is_js_ident_char(c))
6438            && !preceded_by_dot(code, i)
6439    };
6440
6441    // The last two significant (non-whitespace, non-comment) bytes seen in code, and where the
6442    // last one is — for the ASI and regex-vs-division decisions.
6443    let mut last_sig: Option<u8> = None;
6444    let mut prev_sig: Option<u8> = None;
6445    let mut last_sig_idx = 0usize;
6446
6447    while i < bytes.len() {
6448        let c = bytes[i];
6449        // LF, CR, CRLF and U+2028 / U+2029 are all JavaScript line terminators.
6450        let terminator = line_terminator_len(bytes, i);
6451        if state == Code && terminator > 0 {
6452            let next_start = i + terminator;
6453            if depth <= 0 && asi_ends_statement(code, next_start, last_sig, prev_sig, last_sig_idx)
6454            {
6455                return false;
6456            }
6457            at_line_start = true;
6458            i = next_start;
6459            continue;
6460        }
6461        match state {
6462            Code => {
6463                // HTML-like comments (Script goal): `<!--` anywhere, and `-->` at the start of
6464                // a line, comment out the rest of the line — up to ANY line terminator.
6465                let line_comment = bytes[i..].starts_with(b"<!--")
6466                    || (at_line_start && bytes[i..].starts_with(b"-->"))
6467                    || bytes[i..].starts_with(b"//");
6468                if line_comment {
6469                    i = find_line_terminator(bytes, i).unwrap_or(bytes.len());
6470                    continue;
6471                }
6472                // A non-ASCII character: JavaScript whitespace (NBSP, BOM, …) is skipped like
6473                // any whitespace; anything else is read as part of an identifier (`énew` is a
6474                // name, not the keyword `new`). Line terminators were handled above.
6475                if c >= 0x80 {
6476                    let ch = code.get(i..).and_then(|rest| rest.chars().next());
6477                    let len = ch.map_or(1, char::len_utf8);
6478                    if !ch.is_some_and(is_js_space) {
6479                        at_line_start = false;
6480                        prev_sig = last_sig;
6481                        last_sig = Some(c);
6482                        last_sig_idx = i + len - 1;
6483                    }
6484                    i += len;
6485                    continue;
6486                }
6487                if !c.is_ascii_whitespace() {
6488                    at_line_start = false;
6489                }
6490                let in_for_head = brackets.last() == Some(&Bracket::ForHead);
6491                match c {
6492                    b'\'' => state = SingleQuote,
6493                    b'"' => state = DoubleQuote,
6494                    b'`' => state = Template,
6495                    b'/' if bytes.get(i + 1) == Some(&b'*') => {
6496                        let body_start = i + 2;
6497                        let end = code[body_start..]
6498                            .find("*/")
6499                            .map_or(bytes.len(), |n| body_start + n);
6500                        i = (end + 2).min(bytes.len());
6501                        // A block comment spanning a line break IS a line break for ASI:
6502                        // `a = 1 /*\n*/ b = 2` is two statements.
6503                        if find_line_terminator(&bytes[..end], body_start).is_some() {
6504                            if depth <= 0
6505                                && asi_ends_statement(code, i, last_sig, prev_sig, last_sig_idx)
6506                            {
6507                                return false;
6508                            }
6509                            at_line_start = true;
6510                        }
6511                        continue;
6512                    }
6513                    // After `}` a `/` is division if the brace closed an object literal, and a
6514                    // regex if it closed a block — undecidable here, so run the code as-is.
6515                    b'/' if last_sig == Some(b'}') => return false,
6516                    b'/' if (last_sig == Some(b')')
6517                        && control_head_closed_at == Some(last_sig_idx))
6518                        || slash_starts_regex(
6519                            code,
6520                            last_sig,
6521                            prev_sig,
6522                            last_sig_idx,
6523                            in_for_head,
6524                        ) =>
6525                    {
6526                        // A regex literal: skip it whole (a quote or newline-like character
6527                        // inside it must not be read as code), then its flags.
6528                        i = skip_regex_literal(bytes, i);
6529                        prev_sig = last_sig;
6530                        last_sig = Some(b')'); // an operand, like a closed group
6531                        last_sig_idx = i.saturating_sub(1);
6532                        continue;
6533                    }
6534                    b'(' | b'[' | b'{' => {
6535                        depth += 1;
6536                        let head = if c == b'(' {
6537                            control_head_kind(code, last_sig, last_sig_idx)
6538                        } else {
6539                            Bracket::Plain
6540                        };
6541                        brackets.push(head);
6542                    }
6543                    b')' | b']' | b'}' => {
6544                        depth -= 1;
6545                        if brackets.pop().is_some_and(|b| b != Bracket::Plain) && c == b')' {
6546                            control_head_closed_at = Some(i);
6547                        }
6548                        if c == b'}' && template_depths.last() == Some(&depth) {
6549                            template_depths.pop();
6550                            state = Template;
6551                        }
6552                    }
6553                    // A top-level `;` with more CODE after it (not just a comment) is a
6554                    // multi-statement block.
6555                    b';' if depth <= 0 && !strip_leading_js_comments(&code[i + 1..]).is_empty() => {
6556                        return false;
6557                    }
6558                    // An explicit top-level `return` token means the code already returns.
6559                    b'r' if depth <= 0 && is_return_token(i) => return false,
6560                    _ => {}
6561                }
6562                if !c.is_ascii_whitespace() {
6563                    prev_sig = last_sig;
6564                    last_sig = Some(c);
6565                    last_sig_idx = i;
6566                }
6567            }
6568            SingleQuote | DoubleQuote | Template => {
6569                let close = match state {
6570                    SingleQuote => b'\'',
6571                    DoubleQuote => b'"',
6572                    _ => b'`',
6573                };
6574                if c == b'\\' {
6575                    i += 1;
6576                } else if state == Template && c == b'$' && bytes.get(i + 1) == Some(&b'{') {
6577                    template_depths.push(depth);
6578                    brackets.push(Bracket::Plain);
6579                    depth += 1;
6580                    state = Code;
6581                    i += 1;
6582                } else if c == close {
6583                    state = Code;
6584                    prev_sig = last_sig;
6585                    last_sig = Some(c);
6586                    last_sig_idx = i;
6587                }
6588            }
6589        }
6590        i += 1;
6591    }
6592
6593    true
6594}
6595
6596/// An identifier byte: ASCII letter/digit/`_`/`$`, or any byte of a non-ASCII character (the
6597/// scanner steps over non-ASCII whitespace itself, so what remains is part of a name).
6598fn is_js_ident(b: u8) -> bool {
6599    b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || b >= 0x80
6600}
6601
6602/// JavaScript whitespace outside ASCII: the Unicode space separators (NBSP, U+2000…), and BOM.
6603fn is_js_space(c: char) -> bool {
6604    c.is_whitespace() || c == '\u{feff}'
6605}
6606
6607/// A character that can be part of an identifier (approximately: non-ASCII letters are not
6608/// told apart from other non-ASCII symbols, which are syntax errors anyway).
6609fn is_js_ident_char(c: char) -> bool {
6610    if c.is_ascii() {
6611        c.is_ascii_alphanumeric() || c == '_' || c == '$'
6612    } else {
6613        !is_js_space(c) && !matches!(c, '\u{2028}' | '\u{2029}')
6614    }
6615}
6616
6617/// Start of the identifier that ends just before byte `end` (a char boundary); `end` if none.
6618fn ident_start_before(code: &str, end: usize) -> usize {
6619    let Some(head) = code.get(..end) else {
6620        return end;
6621    };
6622    let mut start = end;
6623    for (idx, c) in head.char_indices().rev() {
6624        if !is_js_ident_char(c) {
6625            break;
6626        }
6627        start = idx;
6628    }
6629    start
6630}
6631
6632/// What a `(` opens, given the last significant token before it: an `if`/`while`/`with` head,
6633/// a `for` (or `for await`) head, or anything else.
6634fn control_head_kind(code: &str, last_sig: Option<u8>, last_sig_idx: usize) -> Bracket {
6635    if !last_sig.is_some_and(is_js_ident) {
6636        return Bracket::Plain;
6637    }
6638    match js_keyword_ending_at(code, last_sig_idx) {
6639        "if" | "while" | "with" => Bracket::ControlHead,
6640        "for" => Bracket::ForHead,
6641        "await" => {
6642            // `for await (`: the word before `await`.
6643            let before = code[..=last_sig_idx]
6644                .strip_suffix("await")
6645                .unwrap_or("")
6646                .trim_end();
6647            match before.len().checked_sub(1) {
6648                Some(end) if js_keyword_ending_at(code, end) == "for" => Bracket::ForHead,
6649                _ => Bracket::Plain,
6650            }
6651        }
6652        _ => Bracket::Plain,
6653    }
6654}
6655
6656/// Is the token starting at byte `start` a property name (`obj.of`, `a?.return`)? Such a
6657/// word is never a keyword.
6658fn preceded_by_dot(code: &str, start: usize) -> bool {
6659    code[..start].trim_end().ends_with('.')
6660}
6661
6662/// The KEYWORD candidate that ends at byte `end` (inclusive): the identifier there, or `""`
6663/// when `code[end]` is not an identifier byte or the word is a property name after `.`.
6664fn js_keyword_ending_at(code: &str, end: usize) -> &str {
6665    let bytes = code.as_bytes();
6666    if !bytes.get(end).copied().is_some_and(is_js_ident) || !code.is_char_boundary(end + 1) {
6667        return "";
6668    }
6669    let start = ident_start_before(code, end + 1);
6670    if start > end || preceded_by_dot(code, start) {
6671        return "";
6672    }
6673    &code[start..=end]
6674}
6675
6676/// Keywords after which an expression (not a statement end) must follow.
6677///
6678/// NOT listed: `yield`, which is a plain identifier inside the async-arrow wrapper eval code
6679/// runs in (it is a keyword only in generators and strict code), and `of`, which is a keyword
6680/// only inside a `for (… of …)` head — see [`is_expr_keyword`].
6681const EXPR_KEYWORDS: &[&str] = &[
6682    "instanceof",
6683    "in",
6684    "typeof",
6685    "void",
6686    "delete",
6687    "new",
6688    "await",
6689    "return",
6690    "case",
6691    "do",
6692    "else",
6693    "throw",
6694];
6695
6696/// Whether `word` is a keyword after which an operand must follow. `of` is one only directly
6697/// inside a `for (…)` head (`in_for_head`); anywhere else it is an identifier (`f(of / 2)`, or
6698/// `of` then a new line then `foo()`, which is two statements).
6699fn is_expr_keyword(word: &str, in_for_head: bool) -> bool {
6700    (word == "of" && in_for_head) || EXPR_KEYWORDS.contains(&word)
6701}
6702
6703/// Is the `.` at byte `dot` the end of a numeric literal (`1.`) — a complete operand — rather
6704/// than a member access? Only a plain decimal integer (digits and `_`, not itself after a `.`)
6705/// qualifies: `a1.`, `x.`, `0x1.`, `1e3.`, `1n.` and `1.5.` are member accesses. A misread
6706/// only matters in one direction: reading a member access as a complete number merely skips
6707/// the `return` prepend, which is always safe.
6708fn dot_completes_number(code: &str, dot: usize) -> bool {
6709    let bytes = code.as_bytes();
6710    let start = ident_start_before(code, dot);
6711    let word = &bytes[start..dot];
6712    word.first().is_some_and(u8::is_ascii_digit)
6713        && word.iter().all(|b| b.is_ascii_digit() || *b == b'_')
6714        && !(start > 0 && bytes[start - 1] == b'.')
6715}
6716
6717/// A line (or operand) ending in POSTFIX `++`/`--`.
6718fn ends_in_postfix(last_sig: Option<u8>, prev_sig: Option<u8>) -> bool {
6719    matches!(
6720        (prev_sig, last_sig),
6721        (Some(b'+'), Some(b'+')) | (Some(b'-'), Some(b'-'))
6722    )
6723}
6724
6725/// Does a line break just before `next_start` end the statement (JavaScript ASI)? Only
6726/// consulted at bracket depth 0.
6727fn asi_ends_statement(
6728    code: &str,
6729    next_start: usize,
6730    last_sig: Option<u8>,
6731    prev_sig: Option<u8>,
6732    last_sig_idx: usize,
6733) -> bool {
6734    let rest = strip_leading_js_comments(&code[next_start.min(code.len())..]);
6735    let Some(&next) = rest.as_bytes().first() else {
6736        return false; // nothing follows
6737    };
6738    // Restricted production: `a\n++b` is `a; ++b`.
6739    if rest.starts_with("++") || rest.starts_with("--") {
6740        return true;
6741    }
6742    // After POSTFIX `++`/`--` nothing but a binary operator can continue the expression, and
6743    // `i++\n[…]` / `i++\n(…)` are ASI'd into two statements — treat any following line as a
6744    // new statement (not prepending is always safe; prepending would drop that line).
6745    if ends_in_postfix(last_sig, prev_sig) {
6746        return true;
6747    }
6748    // A line ending in `1.` ends in a complete number, not a member access: `1.` then a new
6749    // line then `f()` is two statements.
6750    let number_dot = last_sig == Some(b'.') && dot_completes_number(code, last_sig_idx);
6751    let continues_after = (!number_dot
6752        && last_sig.is_some_and(|p| ASI_CONTINUES_AFTER.contains(&p)))
6753        || is_expr_keyword(js_keyword_ending_at(code, last_sig_idx), false);
6754    !(continues_after || ASI_CONTINUES_BEFORE.contains(&next))
6755}
6756
6757/// Whether a `/` (not starting a comment) begins a regex literal rather than division:
6758/// true where an operand is expected — at the start, after an operator or opening
6759/// punctuation, or after a keyword such as `return`/`typeof`. After a postfix `++`/`--`
6760/// (a complete operand) it is division. (After the `)` of a control-statement head it is a
6761/// regex too; the caller decides that case.)
6762fn slash_starts_regex(
6763    code: &str,
6764    last_sig: Option<u8>,
6765    prev_sig: Option<u8>,
6766    last_sig_idx: usize,
6767    in_for_head: bool,
6768) -> bool {
6769    if ends_in_postfix(last_sig, prev_sig) {
6770        return false;
6771    }
6772    match last_sig {
6773        None => true,
6774        Some(b) if b"(,=:[!&|?{};+-*%<>~^".contains(&b) => true,
6775        Some(b) if is_js_ident(b) => {
6776            is_expr_keyword(js_keyword_ending_at(code, last_sig_idx), in_for_head)
6777        }
6778        Some(_) => false,
6779    }
6780}
6781
6782/// Skip a regex literal starting at the `/` at `start`; returns the index after its flags.
6783fn skip_regex_literal(bytes: &[u8], start: usize) -> usize {
6784    let mut i = start + 1;
6785    let mut in_class = false;
6786    while i < bytes.len() {
6787        match bytes[i] {
6788            b'\\' => i += 1,
6789            b'[' => in_class = true,
6790            b']' => in_class = false,
6791            b'/' if !in_class => break,
6792            // Unterminated at a line terminator (any of them): let it be scanned normally.
6793            _ if line_terminator_len(bytes, i) > 0 => return i,
6794            _ => {}
6795        }
6796        i += 1;
6797    }
6798    i += 1;
6799    while i < bytes.len() && is_js_ident(bytes[i]) {
6800        i += 1;
6801    }
6802    i
6803}
6804
6805#[cfg(test)]
6806mod prop_tests {
6807    //! Property-based tests for the eval auto-return heuristic — the code that
6808    //! caused the worst bug in the system (silent corruption of multi-statement
6809    //! eval) and has bitten twice. These generate many JS-ish snippets and
6810    //! assert the invariants that keep eval correct.
6811    use super::should_prepend_return;
6812    use proptest::prelude::*;
6813
6814    /// A small set of non-keyword identifier-ish expressions.
6815    fn ident() -> impl Strategy<Value = String> {
6816        prop_oneof![
6817            Just("a".to_string()),
6818            Just("x".to_string()),
6819            Just("foo".to_string()),
6820            Just("window.x".to_string()),
6821            Just("document.title".to_string()),
6822            Just("obj.prop".to_string()),
6823            Just("arr[0]".to_string()),
6824            Just("localStorage".to_string()),
6825        ]
6826    }
6827
6828    /// A single bare expression: never starts with a statement keyword, has no
6829    /// top-level `;`, and contains no `return`.
6830    fn bare_expr() -> impl Strategy<Value = String> {
6831        prop_oneof![
6832            ident(),
6833            (ident(), ident()).prop_map(|(a, b)| format!("{a} + {b}")),
6834            (ident(), ident()).prop_map(|(a, b)| format!("{a}({b})")),
6835            ident().prop_map(|a| format!("{a}.length")),
6836            any::<u16>().prop_map(|n| n.to_string()),
6837        ]
6838    }
6839
6840    proptest! {
6841        /// Must never panic or hang on ANY input — including malformed code,
6842        /// unbalanced quotes, and arbitrary unicode (the scanner indexes bytes).
6843        #[test]
6844        fn never_panics_on_arbitrary_input(s in ".{0,256}") {
6845            let _ = should_prepend_return(&s);
6846        }
6847
6848        /// A single bare expression is safe to wrap with `return` → true.
6849        #[test]
6850        fn bare_expressions_are_prepended(e in bare_expr()) {
6851            prop_assert!(should_prepend_return(&e), "bare expr not prepended: {e:?}");
6852        }
6853
6854        /// THE critical bug class: `<expr>; return <expr>` must NOT be prepended
6855        /// (else `return <expr>;` runs and the rest is silently discarded).
6856        #[test]
6857        fn semicolon_multistatement_with_return_never_prepended(
6858            setup in bare_expr(), ret in bare_expr()
6859        ) {
6860            let code = format!("{setup}; return {ret}");
6861            prop_assert!(!should_prepend_return(&code), "would corrupt: {code:?}");
6862        }
6863
6864        /// Newline-separated (ASI) explicit return must also be left as-is.
6865        #[test]
6866        fn newline_explicit_return_never_prepended(pre in bare_expr(), ret in bare_expr()) {
6867            let code = format!("{pre}\nreturn {ret}");
6868            prop_assert!(!should_prepend_return(&code), "explicit return prepended: {code:?}");
6869        }
6870
6871        /// A line after a postfix `++`/`--`, or after a keyword-named PROPERTY (`obj.of`,
6872        /// `obj.in`), starts a new statement: prepending would silently drop it (audit V-6).
6873        #[test]
6874        fn statement_after_postfix_or_keyword_property_never_prepended(
6875            a in ident(), b in bare_expr(), op in prop_oneof![Just("++"), Just("--")],
6876            open in prop_oneof![Just("["), Just("(")], prop in prop_oneof![Just("of"), Just("in")]
6877        ) {
6878            let close = if open == "[" { "]" } else { ")" };
6879            let postfix = format!("{a}{op}\n{open}{b}{close}");
6880            prop_assert!(!should_prepend_return(&postfix), "would drop a line: {postfix:?}");
6881            let keyword_prop = format!("{a}.{prop}\n{b}");
6882            prop_assert!(!should_prepend_return(&keyword_prop), "would drop a line: {keyword_prop:?}");
6883        }
6884
6885        /// `;` or the word `return` INSIDE a string literal must not trigger a
6886        /// false multi-statement split — a bare string is one expression.
6887        #[test]
6888        fn semicolons_and_return_inside_strings_are_ignored(inner in "[a-z0-9;= ]{0,24}") {
6889            // `inner` never contains a quote, so the literal is well-formed.
6890            let code = format!("'do;not;split return {inner}'");
6891            prop_assert!(should_prepend_return(&code), "string literal mis-split: {code:?}");
6892        }
6893    }
6894}
6895
6896#[cfg(test)]
6897mod tests {
6898    use super::*;
6899
6900    #[cfg(feature = "sqlite")]
6901    #[test]
6902    fn database_path_resolution_rejects_lexical_escape() {
6903        let dir = tempfile::tempdir().unwrap();
6904        let root = dir.path().join("allowed");
6905        std::fs::create_dir(&root).unwrap();
6906        std::fs::File::create(dir.path().join("outside.db")).unwrap();
6907
6908        let err =
6909            VictauriMcpHandler::resolve_existing_db_path(&[root], "../outside.db").unwrap_err();
6910        assert!(err.contains("path traversal"), "unexpected error: {err}");
6911    }
6912
6913    #[cfg(feature = "sqlite")]
6914    #[test]
6915    fn database_path_resolution_accepts_contained_nested_file() {
6916        let dir = tempfile::tempdir().unwrap();
6917        let root = dir.path().join("allowed");
6918        let nested = root.join("nested");
6919        std::fs::create_dir_all(&nested).unwrap();
6920        let db = nested.join("app.db");
6921        std::fs::File::create(&db).unwrap();
6922
6923        let resolved =
6924            VictauriMcpHandler::resolve_existing_db_path(&[root], "nested/app.db").unwrap();
6925        // Resolution returns the CANONICAL validated path (opened == validated, closing the
6926        // lexical-vs-canonical TOCTOU), so compare against the canonical form of the target.
6927        assert_eq!(resolved, std::fs::canonicalize(&db).unwrap());
6928    }
6929
6930    #[cfg(all(feature = "sqlite", unix))]
6931    #[test]
6932    fn database_path_resolution_rejects_symlink_escape() {
6933        use std::os::unix::fs::symlink;
6934
6935        let dir = tempfile::tempdir().unwrap();
6936        let root = dir.path().join("allowed");
6937        std::fs::create_dir(&root).unwrap();
6938        let outside = dir.path().join("outside.db");
6939        std::fs::File::create(&outside).unwrap();
6940        symlink(&outside, root.join("linked.db")).unwrap();
6941
6942        let err =
6943            VictauriMcpHandler::resolve_existing_db_path(std::slice::from_ref(&root), "linked.db")
6944                .unwrap_err();
6945        // Audit F6: an escape answers exactly like a miss (no existence oracle).
6946        let miss = VictauriMcpHandler::resolve_existing_db_path(&[root], "absent.db").unwrap_err();
6947        assert!(
6948            err.contains("database not found"),
6949            "unexpected error: {err}"
6950        );
6951        assert_eq!(
6952            err.replace("linked.db", "X"),
6953            miss.replace("absent.db", "X")
6954        );
6955    }
6956
6957    #[cfg(feature = "sqlite")]
6958    #[test]
6959    fn sqlite_identifier_quoting_handles_hostile_table_names() {
6960        let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap();
6961        let conn = rusqlite::Connection::open(file.path()).unwrap();
6962        let name = "odd\"] table";
6963        let identifier = crate::database::quote_sqlite_identifier(name);
6964        conn.execute_batch(&format!(
6965            "CREATE TABLE {identifier} (id INTEGER); INSERT INTO {identifier} VALUES (1);"
6966        ))
6967        .unwrap();
6968        let count: i64 = conn
6969            .query_row(&format!("SELECT count(*) FROM {identifier}"), [], |row| {
6970                row.get(0)
6971            })
6972            .unwrap();
6973        assert_eq!(count, 1);
6974    }
6975
6976    #[test]
6977    fn env_filter_drops_secrets_keeps_safe() {
6978        // Safe, non-secret vars pass.
6979        assert!(is_safe_env_key("HOME"));
6980        assert!(is_safe_env_key("LANG"));
6981        assert!(is_safe_env_key("TAURI_ENV_PLATFORM"));
6982        assert!(is_safe_env_key("VICTAURI_PORT"));
6983        // Secret-looking vars are dropped even under a safe prefix (audit #5).
6984        assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY"));
6985        assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"));
6986        assert!(!is_safe_env_key("VICTAURI_AUTH_TOKEN"));
6987        assert!(!is_safe_env_key("VICTAURI_API_KEY"));
6988        // Unknown prefixes are dropped regardless.
6989        assert!(!is_safe_env_key("AWS_SECRET_ACCESS_KEY"));
6990        assert!(!is_safe_env_key("RANDOM_VAR"));
6991        // The broad TAURI_ namespace is no longer allowed — only TAURI_ENV_ — so
6992        // app-custom TAURI_ secrets are dropped even without a denylist hit.
6993        assert!(!is_safe_env_key("TAURI_CUSTOM_THING"));
6994        // Adversarial leaks closed (audit #5 follow-up): connection strings,
6995        // passphrases, PATs, JWTs, etc. under an allowed prefix.
6996        assert!(!is_safe_env_key("VICTAURI_DB_DSN"));
6997        assert!(!is_safe_env_key("VICTAURI_SIGNING_PASSPHRASE"));
6998        assert!(!is_safe_env_key("VICTAURI_GH_PAT"));
6999        assert!(!is_safe_env_key("VICTAURI_JWT"));
7000        assert!(!is_safe_env_key("VICTAURI_SESSION_ID"));
7001    }
7002
7003    #[test]
7004    fn prepend_return_newline_separated_statements_are_not_wrapped() {
7005        // ASI: `return foo()\nbar()` returns foo() and never runs bar().
7006        assert!(!should_prepend_return("foo()\nbar()"));
7007        assert!(!should_prepend_return("window.x = 1\nwindow.x + 1"));
7008        assert!(!should_prepend_return("foo() // note\nbar()"));
7009        // …but an expression that visibly continues across lines is still one expression.
7010        assert!(should_prepend_return(
7011            "document\n  .querySelector('x')\n  .textContent"
7012        ));
7013        assert!(should_prepend_return("a +\n  b"));
7014        assert!(should_prepend_return("cond\n  ? a\n  : b"));
7015        assert!(should_prepend_return("[1, 2].map(x =>\n  x * 2)"));
7016        assert!(should_prepend_return("`line1\nline2`"));
7017        assert!(should_prepend_return("document.title\n"));
7018    }
7019
7020    /// The live tool list (name + description), in router order — the source of truth for the
7021    /// CLI bridge's baked `tools_fallback.json` (what an agent sees while the app is down).
7022    fn live_tool_manifest() -> serde_json::Value {
7023        let mut tools = VictauriMcpHandler::tool_router().list_all();
7024        tools.sort_by(|a, b| a.name.cmp(&b.name));
7025        serde_json::Value::Array(
7026            tools
7027                .iter()
7028                .map(|t| {
7029                    serde_json::json!({
7030                        "name": t.name.as_ref(),
7031                        "description": t.description.as_deref().unwrap_or_default(),
7032                    })
7033                })
7034                .collect(),
7035        )
7036    }
7037
7038    fn fallback_manifest_path() -> std::path::PathBuf {
7039        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
7040            .join("../victauri-cli/src/tools_fallback.json")
7041    }
7042
7043    #[test]
7044    fn cli_fallback_tool_manifest_matches_the_live_tools() {
7045        // Names AND descriptions: a stale description shown while the app is down misleads an
7046        // agent just as much as a missing tool. Regenerate with:
7047        //   VICTAURI_WRITE_FALLBACK=1 cargo test -p victauri-plugin --lib cli_fallback
7048        let path = fallback_manifest_path();
7049        let Ok(raw) = std::fs::read_to_string(&path) else {
7050            return; // packaged crate: the CLI source is not alongside
7051        };
7052        let live = live_tool_manifest();
7053        if std::env::var_os("VICTAURI_WRITE_FALLBACK").is_some() {
7054            let pretty = serde_json::to_string_pretty(&live).unwrap() + "\n";
7055            std::fs::write(&path, pretty).unwrap();
7056            return;
7057        }
7058        let mut baked: Vec<serde_json::Value> = serde_json::from_str(&raw).unwrap();
7059        baked.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
7060        assert_eq!(
7061            serde_json::Value::Array(baked),
7062            live,
7063            "crates/victauri-cli/src/tools_fallback.json is stale — regenerate it with \
7064             VICTAURI_WRITE_FALLBACK=1 cargo test -p victauri-plugin --lib cli_fallback"
7065        );
7066    }
7067
7068    #[cfg(feature = "sqlite")]
7069    #[test]
7070    fn db_path_outside_roots_gives_no_existence_oracle() {
7071        let root = tempfile::tempdir().unwrap();
7072        let roots = vec![root.path().to_path_buf()];
7073        let outside_existing = std::env::current_exe().unwrap();
7074        let outside_missing = outside_existing.with_file_name("definitely-not-here-7f3a.db");
7075        let e1 = VictauriMcpHandler::resolve_existing_db_path(
7076            &roots,
7077            outside_existing.to_str().unwrap(),
7078        )
7079        .unwrap_err();
7080        let e2 =
7081            VictauriMcpHandler::resolve_existing_db_path(&roots, outside_missing.to_str().unwrap())
7082                .unwrap_err();
7083        assert!(e1.contains("not within an allowed directory"), "{e1}");
7084        assert!(e2.contains("not within an allowed directory"), "{e2}");
7085        assert!(!e1.contains("not found") && !e2.contains("not found"));
7086        // `..` is refused outright in an absolute path (audit F6: the OS resolves it after
7087        // following any symlink, so lexical normalization cannot vouch for it).
7088        let climb = root.path().join("..").join("x.db");
7089        let e3 = VictauriMcpHandler::resolve_existing_db_path(&roots, climb.to_str().unwrap())
7090            .unwrap_err();
7091        assert!(e3.contains("'..' is rejected"), "{e3}");
7092    }
7093
7094    #[test]
7095    fn prepend_return_handles_the_red_team_asi_cases() {
7096        // Two statements that must NOT be wrapped (the second line would silently never run).
7097        for code in [
7098            "window.x++\nwindow.x",
7099            "window.x--\nwindow.x",
7100            "a\n++b",
7101            "/re/.test(s)\nfoo()",
7102            "s.replace(/\"/g, '')\nfoo()",
7103            "foo()\u{2028}bar()",
7104            "foo()\u{2029}bar()",
7105        ] {
7106            assert!(!should_prepend_return(code), "must not wrap: {code:?}");
7107        }
7108        // Valid single expressions spanning lines, which must still be wrapped.
7109        for code in [
7110            "a instanceof\nB",
7111            "typeof\nx",
7112            "await\nfoo()",
7113            "new\nFoo()",
7114            "a /\nb",
7115            "s.split(/,/)\n.length",
7116            "document.title; // trailing note",
7117            "document.title // trailing note",
7118            "obj.return",
7119            "obj.of\n.length",
7120            "i++ / 2",
7121            "`a${'`'}b`",
7122        ] {
7123            assert!(should_prepend_return(code), "must wrap: {code:?}");
7124        }
7125        // Round 2 (0.9 audit V-6): each of these used to be wrapped and silently lost a
7126        // statement, or was wrapped into a syntax error.
7127        for code in ASI_ROUND2_STATEMENT_CASES {
7128            assert!(!should_prepend_return(code), "must not wrap: {code:?}");
7129        }
7130    }
7131
7132    /// Run a Node script and return its stdout, or `None` (test skipped) without `node`.
7133    fn run_node(script: &str) -> Option<String> {
7134        let out = std::process::Command::new("node")
7135            .arg("-e")
7136            .arg(script)
7137            .output()
7138            .ok()?;
7139        Some(String::from_utf8_lossy(&out.stdout).into_owned())
7140    }
7141
7142    #[test]
7143    fn log_field_truncation_never_splits_a_surrogate_pair() {
7144        // An emoji straddling the MAX_LOG_FIELD_BYTES cut used to leave a lone high surrogate,
7145        // which serde_json rejects — failing the whole `logs` read (audit V-4).
7146        let source = format!(
7147            "[{{ body: 'a'.repeat({}) + '\\u{{1F600}}tail' }}]",
7148            MAX_LOG_FIELD_BYTES - 1
7149        );
7150        let code = trimmed_log_js(&source, 10);
7151        let script = format!(
7152            "const r = (function() {{ {code} }})(); \
7153             console.log('OUT:' + r[0].body.isWellFormed() + ':' + r[0].body.indexOf('bytes truncated'));"
7154        );
7155        let Some(out) = run_node(&script) else {
7156            eprintln!("SKIP: node not installed");
7157            return;
7158        };
7159        let line = out
7160            .lines()
7161            .find_map(|l| l.strip_prefix("OUT:"))
7162            .unwrap_or_else(|| panic!("no output: {out}"));
7163        let (well_formed, marker_at) = line.split_once(':').unwrap();
7164        assert_eq!(
7165            well_formed, "true",
7166            "truncated field is not well-formed UTF-16"
7167        );
7168        assert_ne!(marker_at, "-1", "field was not truncated");
7169    }
7170
7171    /// Multi-statement snippets from the 0.9 red-team round (audit V-6): each must run every
7172    /// statement. `f()` pushes `'f'` onto the log.
7173    const ASI_ROUND2_STATEMENT_CASES: &[&str] = &[
7174        "i++ / 2; f()",
7175        "x = {} / 1; f()",
7176        "i++\n[f()]",
7177        "i++\n(f)()",
7178        "i--\n[f()]",
7179        "i--\n(f)()",
7180        "obj.of\nf()",
7181        "obj.in\nf()",
7182        "if\t(true) f()",
7183        "if\n(true) f()",
7184        "const\ny = 5; f()",
7185        "outer: for (const a of [1]) { f() }",
7186        "outer:\nfor (const a of [1]) { f() }",
7187        "`a${'`'}b`; f()",
7188        "`${'`'}`\nf()",
7189        "f() <!-- don't\nf()",
7190        "f()\n--> it's a comment\nf()",
7191        "a = /[/]/\nf()",
7192        "typeof x / 2; f()",
7193    ];
7194
7195    /// Multi-statement snippets from the 0.9 round-4 audit (R4-EVAL2): each was wrapped with
7196    /// `return` and silently lost everything after its first statement.
7197    const ASI_ROUND4_STATEMENT_CASES: &[&str] = &[
7198        // (a) a block comment spanning a line break is a line break
7199        "x = 1 /*\n*/ f()",
7200        "x = 1 /*\r*/ f()",
7201        "x = 1 /*\u{2028}*/ f()",
7202        "x = 1 /* a\n b */ f()",
7203        // (b) a lone CR (and CRLF) is a line terminator everywhere
7204        "x = 1\rf()",
7205        "x = 1\r\nf()",
7206        "x = 1 // note\rf()",
7207        "x = 1 // note\u{2028}f()",
7208        "f() <!-- c\rf()",
7209        "f()\r--> c\rf()",
7210        "x = /a/g\rf()",
7211        // (c) a number ending in `.` is a complete operand
7212        "x = 1.\nf()",
7213        "x = 1_0.\rf()",
7214        "x = 10.\n\nf()",
7215        // (d) `yield` is an identifier in the eval wrapper; `of` is one at depth 0
7216        "x = typeof yield\nf()",
7217        "x = typeof of\nf()",
7218    ];
7219
7220    /// Multi-statement snippets from the 0.9 round-5 audit (R5-EVAL1): each was wrapped with
7221    /// `return` and lost its second statement. (1) a regex right after the `)` of an
7222    /// `if`/`while`/`for`/`with` head was read as division, so a quote or bracket inside it
7223    /// broke the scan; (2) `of` was a keyword anywhere inside brackets; (3) a non-ASCII
7224    /// identifier ending in a keyword (`énew`) was read as that keyword.
7225    const ASI_ROUND5_STATEMENT_CASES: &[&str] = &[
7226        "(function(){ if (a) /'/.test('q') })(); f()",
7227        "(function(){ if (a) /\\(/.test('q') })(); f()",
7228        "(function(){ if (a) /\"/.test('q') })(); f()",
7229        "(function(){ if (a) /`/.test('q') })(); f()",
7230        "(function(){ if (a) /[(]/.test('q') })(); f()",
7231        "(function(){ if (a) /\\[/.test('q') })(); f()",
7232        "(function(){ if (a) /\\{/.test('q') })(); f()",
7233        "(function(){ if ((a)) /'/.test('q') })(); f()",
7234        "(function(){ while (a) /'/.test('q') })(); f()",
7235        "(function(){ for (;a;) /'/.test('q') })(); f()",
7236        "(function(){ with (obj) /'/.test('q') })(); f()",
7237        "(async function(){ for await (const q of []) /'/.test('q') })(); f()",
7238        "(() => { if (a) /'/.test('q') })(); f()",
7239        "Math.abs(of / 2); f()",
7240        "Math.abs(of / 2); f() / 1",
7241        "[of / 2]; f()",
7242        "énew / 2; f() / 1",
7243        "x = énew / 2; f()",
7244        "x = \u{e9}typeof / 2; f()",
7245    ];
7246
7247    #[test]
7248    fn prepend_return_round5_shapes() {
7249        for code in ASI_ROUND5_STATEMENT_CASES {
7250            assert!(!should_prepend_return(code), "must not wrap: {code:?}");
7251        }
7252        for code in [
7253            "(x) / 2 / 1",
7254            "f(x) / 2",
7255            "f(of / 2)",
7256            "[1, 2].map(of => of / 2)",
7257            "énew / 2",
7258            "if_ (a) / 2",
7259            "obj.if (a) / 2",
7260            "(a) / 2; ",
7261        ] {
7262            assert!(should_prepend_return(code), "must wrap: {code:?}");
7263        }
7264    }
7265
7266    /// Table for R4-EVAL2: `(code, wrap?)` — every shape the fix touches, both ways.
7267    #[test]
7268    fn prepend_return_round4_shapes() {
7269        for code in ASI_ROUND4_STATEMENT_CASES {
7270            assert!(!should_prepend_return(code), "must not wrap: {code:?}");
7271        }
7272        for code in [
7273            "x /* no line break */ + 1",
7274            "x /*\n*/ + 1",
7275            "x +\r1",
7276            "x\r.toString()",
7277            "a instanceof\r\nB",
7278            "1.5\n.toFixed(1)",
7279            "obj.of.\nlength",
7280            "0x10.\ntoString()",
7281            "1e3.\ntoFixed(0)",
7282            "(1).\ntoFixed(0)",
7283            "document.title // trailing note\r",
7284            "-->x\ndocument.title",
7285            "<!-- x\rdocument.title",
7286            "// lead\u{2028}document.title",
7287            "[1].map(y => { for (const z of\n[2]) {} })",
7288        ] {
7289            assert!(should_prepend_return(code), "must wrap: {code:?}");
7290        }
7291    }
7292
7293    #[test]
7294    fn line_terminators_and_number_dots_are_recognized() {
7295        for (s, at, len) in [
7296            ("a\nb", 1, 1),
7297            ("a\rb", 1, 1),
7298            ("a\r\nb", 1, 2),
7299            ("a\u{2028}b", 1, 3),
7300            ("a\u{2029}b", 1, 3),
7301            ("a b", 1, 0),
7302            ("a\u{2027}b", 1, 0),
7303        ] {
7304            assert_eq!(line_terminator_len(s.as_bytes(), at), len, "{s:?}");
7305        }
7306        for (code, complete) in [
7307            ("1.", true),
7308            ("x = 10.", true),
7309            ("1_000.", true),
7310            ("a1.", false),
7311            ("x.", false),
7312            ("0x1.", false),
7313            ("1e3.", false),
7314            ("1n.", false),
7315            ("1.5.", false),
7316            ("(1).", false),
7317            ("1 .", false),
7318        ] {
7319            assert_eq!(
7320                dot_completes_number(code, code.len() - 1),
7321                complete,
7322                "{code:?}"
7323            );
7324        }
7325    }
7326
7327    /// Run each `(code, expected_return, expected_log)` through the REAL eval wrapper shape
7328    /// (the code inlined in an async arrow, prepended with `return` exactly when
7329    /// [`should_prepend_return`] says so) in Node, and check every statement ran and the
7330    /// right value came back. Skips when `node` is not installed.
7331    #[test]
7332    fn prepend_return_decisions_run_every_statement_in_node() {
7333        let mut cases: Vec<(&str, &str, Vec<&str>)> = ASI_ROUND2_STATEMENT_CASES
7334            .iter()
7335            .chain(ASI_ROUND4_STATEMENT_CASES)
7336            .chain(ASI_ROUND5_STATEMENT_CASES)
7337            .map(|c| {
7338                let n = c.matches("f()").count() + c.matches("(f)()").count();
7339                (*c, "undefined", vec!["f"; n])
7340            })
7341            .collect();
7342        cases.extend([
7343            ("obj.return", "\"RET\"", vec![]),
7344            ("obj.of", "\"OF\"", vec![]),
7345            ("obj.in\n.length", "2", vec![]),
7346            ("i++ / 2", "0.5", vec![]),
7347            ("`a${'`'}b`", "\"a`b\"", vec![]),
7348            ("f() <!-- trailing html comment", "1", vec!["f"]),
7349            ("f()\n+ 1", "2", vec!["f"]),
7350            ("document", "\"doc\"", vec![]),
7351            ("x +\r1", "1", vec![]),
7352            ("x /*\n*/ + 1", "1", vec![]),
7353            ("obj.of.\nlength", "2", vec![]),
7354            ("0x10.\ntoString()", "\"16\"", vec![]),
7355            ("-->x\ndocument", "\"doc\"", vec![]),
7356        ]);
7357        let bodies: Vec<String> = cases
7358            .iter()
7359            .map(|(code, _, _)| {
7360                let body = strip_leading_js_comments(code.trim());
7361                if should_prepend_return(body) {
7362                    format!("return {body}")
7363                } else {
7364                    code.trim().to_string()
7365                }
7366            })
7367            .collect();
7368        let mut file = tempfile::NamedTempFile::new().unwrap();
7369        std::io::Write::write_all(
7370            &mut file,
7371            serde_json::to_string(&bodies).unwrap().as_bytes(),
7372        )
7373        .unwrap();
7374        let runner = r"
7375            const vm = require('vm');
7376            const bodies = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'));
7377            (async () => {
7378              const out = [];
7379              for (const body of bodies) {
7380                const log = [];
7381                globalThis.__log = log;
7382                const src = '(async () => { const log = globalThis.__log; const f = () => log.push(\'f\');'
7383                  + ' let i = 1, x = 0, a, of = 4, énew = 2, étypeof = 3; const document = \'doc\';'
7384                  + ' const obj = { of: \'OF\', in: \'IN\', return: \'RET\' };\n' + body + '\n })()';
7385                let ret, err = null;
7386                try { ret = await vm.runInThisContext(src); } catch (e) { err = e.name + ': ' + e.message; }
7387                out.push({ ret: ret === undefined ? 'undefined' : JSON.stringify(ret), log, err });
7388              }
7389              console.log('ASI_RESULTS:' + JSON.stringify(out));
7390            })();
7391        ";
7392        let Ok(output) = std::process::Command::new("node")
7393            .arg("-e")
7394            .arg(runner)
7395            .arg(file.path())
7396            .output()
7397        else {
7398            eprintln!("SKIP: node not installed");
7399            return;
7400        };
7401        let stdout = String::from_utf8_lossy(&output.stdout);
7402        let line = stdout
7403            .lines()
7404            .find_map(|l| l.strip_prefix("ASI_RESULTS:"))
7405            .unwrap_or_else(|| {
7406                panic!(
7407                    "no results: {stdout}\n{}",
7408                    String::from_utf8_lossy(&output.stderr)
7409                )
7410            });
7411        let results: Vec<serde_json::Value> = serde_json::from_str(line).unwrap();
7412        let mut failures = Vec::new();
7413        for (((code, want_ret, want_log), body), got) in cases.iter().zip(&bodies).zip(&results) {
7414            let got_log: Vec<&str> = got["log"]
7415                .as_array()
7416                .unwrap()
7417                .iter()
7418                .filter_map(|v| v.as_str())
7419                .collect();
7420            if !got["err"].is_null() || got["ret"] != *want_ret || got_log != *want_log {
7421                failures.push(format!(
7422                    "{code:?} (ran as {body:?}): got ret={} log={got_log:?} err={}; want ret={want_ret} log={want_log:?}",
7423                    got["ret"], got["err"]
7424                ));
7425            }
7426        }
7427        assert!(failures.is_empty(), "\n{}", failures.join("\n"));
7428    }
7429
7430    #[test]
7431    fn prepend_return_sees_past_leading_comments() {
7432        assert!(!should_prepend_return("// setup\nconst x = 1; x"));
7433        assert!(!should_prepend_return("/* c */ if (a) b()"));
7434        assert!(should_prepend_return("// read it\ndocument.title"));
7435        assert_eq!(
7436            strip_leading_js_comments("// a\n/* b */  document.title"),
7437            "document.title"
7438        );
7439        assert_eq!(strip_leading_js_comments("// only a comment"), "");
7440    }
7441
7442    #[test]
7443    fn prepend_return_bare_expressions() {
7444        assert!(should_prepend_return("document.title"));
7445        assert!(should_prepend_return("5 + 5"));
7446        assert!(should_prepend_return("\"justexpr\""));
7447        assert!(should_prepend_return("await fetch('/x')"));
7448        assert!(should_prepend_return(
7449            "document.querySelectorAll('a').length"
7450        ));
7451        assert!(should_prepend_return("x ? a : b"));
7452        // Single trailing semicolon on a bare expression is still an expression.
7453        assert!(should_prepend_return("document.title;"));
7454        // Semicolons inside strings must not be treated as boundaries.
7455        assert!(should_prepend_return("'a;b;c'"));
7456        assert!(should_prepend_return("\"x;y\".length"));
7457        // IIFE workaround: the `;` lives inside the arrow body (depth > 0).
7458        assert!(should_prepend_return("(()=>{window.x=5; return 'ok'})()"));
7459    }
7460
7461    #[test]
7462    fn no_prepend_for_statement_blocks() {
7463        // The original silent-corruption cases.
7464        assert!(!should_prepend_return(
7465            "localStorage.setItem('k','v'); return localStorage.getItem('k')"
7466        ));
7467        assert!(!should_prepend_return(
7468            "window.scrollTo(0,50); return window.scrollY"
7469        ));
7470        assert!(!should_prepend_return("console.log('x'); return 123"));
7471        assert!(!should_prepend_return("window.__z=7; return 'ok'"));
7472        // Explicit return without a preceding semicolon (newline-separated).
7473        assert!(!should_prepend_return("window.x = 5\nreturn window.x"));
7474    }
7475
7476    #[test]
7477    fn no_prepend_for_statement_keywords() {
7478        assert!(!should_prepend_return("return 42"));
7479        assert!(!should_prepend_return("const x = 1; return x"));
7480        assert!(!should_prepend_return("let y = 2"));
7481        assert!(!should_prepend_return("var z = 3"));
7482        assert!(!should_prepend_return("if (x) { return 1 }"));
7483        assert!(!should_prepend_return("for (const x of y) doThing(x)"));
7484        assert!(!should_prepend_return("throw new Error('x')"));
7485        assert!(!should_prepend_return("function f(){}"));
7486        assert!(!should_prepend_return("{ a: 1 }")); // object-literal-as-block ambiguity → as-is
7487    }
7488
7489    #[test]
7490    fn empty_code_no_prepend() {
7491        assert!(!should_prepend_return(""));
7492        assert!(!should_prepend_return("   "));
7493    }
7494
7495    #[test]
7496    fn envelope_unwrap_value() {
7497        assert_eq!(
7498            unwrap_eval_envelope(r#"{"__victauri_ok":"4DA","__victauri_type":"value"}"#.into()),
7499            Ok("\"4DA\"".to_string())
7500        );
7501        assert_eq!(
7502            unwrap_eval_envelope(r#"{"__victauri_ok":42,"__victauri_type":"value"}"#.into()),
7503            Ok("42".to_string())
7504        );
7505    }
7506
7507    #[test]
7508    fn envelope_unwrap_undefined_null() {
7509        assert_eq!(
7510            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#.into()),
7511            Ok("undefined".to_string())
7512        );
7513        assert_eq!(
7514            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"null"}"#.into()),
7515            Ok("null".to_string())
7516        );
7517    }
7518
7519    #[test]
7520    fn envelope_unwrap_error() {
7521        let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into());
7522        assert!(r.unwrap_err().message.contains("boom"));
7523    }
7524
7525    #[test]
7526    fn envelope_unwrap_deeply_nested_does_not_leak() {
7527        // Build an envelope whose value is nested far deeper than serde_json's
7528        // default recursion limit (128). The full parse fails, so the slice
7529        // fallback must return the value — NOT the raw `__victauri_ok` envelope.
7530        let mut value = String::from("0");
7531        for _ in 0..300 {
7532            value = format!("{{\"n\":{value}}}");
7533        }
7534        let raw = format!(r#"{{"__victauri_ok":{value},"__victauri_type":"value"}}"#);
7535        let out = unwrap_eval_envelope(raw).unwrap();
7536        assert!(
7537            out.starts_with(r#"{"n":"#),
7538            "deep value should be unwrapped, got: {}",
7539            &out[..out.len().min(40)]
7540        );
7541        assert!(
7542            !out.contains("__victauri_ok"),
7543            "envelope must not leak into the result"
7544        );
7545    }
7546
7547    #[test]
7548    fn js_string_simple() {
7549        assert_eq!(js_string("hello"), "\"hello\"");
7550    }
7551
7552    #[test]
7553    fn js_string_single_quotes() {
7554        let result = js_string("it's a test");
7555        assert!(result.contains("it's a test"));
7556    }
7557
7558    #[test]
7559    fn js_string_double_quotes() {
7560        let result = js_string(r#"say "hello""#);
7561        assert!(result.contains(r#"\""#));
7562    }
7563
7564    #[test]
7565    fn js_string_backslashes() {
7566        let result = js_string(r"path\to\file");
7567        assert!(result.contains(r"\\"));
7568    }
7569
7570    #[test]
7571    fn js_string_newlines_and_tabs() {
7572        let result = js_string("line1\nline2\ttab");
7573        assert!(result.contains(r"\n"));
7574        assert!(result.contains(r"\t"));
7575        assert!(!result.contains('\n'));
7576    }
7577
7578    #[test]
7579    fn js_string_null_bytes() {
7580        let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
7581        let result = js_string(&input);
7582        // serde_json escapes null bytes as
7583        assert!(result.contains("\\u0000"));
7584        assert!(!result.contains('\0'));
7585    }
7586
7587    #[test]
7588    fn js_string_template_literal_injection() {
7589        let result = js_string("`${alert(1)}`");
7590        // Should not contain unescaped backticks that could break template literals
7591        // serde_json wraps in double quotes, so backticks are safe
7592        assert!(result.starts_with('"'));
7593        assert!(result.ends_with('"'));
7594    }
7595
7596    #[test]
7597    fn js_string_unicode_separators() {
7598        // U+2028 (Line Separator) and U+2029 (Paragraph Separator) are valid in
7599        // JSON strings per RFC 8259, and serde_json passes them through literally.
7600        // Since js_string is used inside JS double-quoted strings (not template
7601        // literals), they are safe in modern JS engines (ES2019+).
7602        let result = js_string("a\u{2028}b\u{2029}c");
7603        // Verify the string is valid JSON that round-trips correctly
7604        let decoded: String = serde_json::from_str(&result).unwrap();
7605        assert_eq!(decoded, "a\u{2028}b\u{2029}c");
7606    }
7607
7608    #[test]
7609    fn js_string_empty() {
7610        assert_eq!(js_string(""), "\"\"");
7611    }
7612
7613    #[test]
7614    fn js_string_html_script_close() {
7615        // </script> in a JS string inside HTML could break out of script tags
7616        let result = js_string("</script><img onerror=alert(1)>");
7617        assert!(result.starts_with('"'));
7618        // The string is JSON-encoded; verify it round-trips safely
7619        let decoded: String = serde_json::from_str(&result).unwrap();
7620        assert_eq!(decoded, "</script><img onerror=alert(1)>");
7621    }
7622
7623    #[test]
7624    fn js_string_very_long() {
7625        let long = "a".repeat(100_000);
7626        let result = js_string(&long);
7627        assert!(result.len() >= 100_002); // quotes + content
7628    }
7629
7630    // ── URL validation tests ────────────────────────────────────────────────
7631
7632    #[test]
7633    fn url_allows_http() {
7634        assert!(validate_url("http://example.com", false).is_ok());
7635    }
7636
7637    #[test]
7638    fn url_allows_https() {
7639        assert!(validate_url("https://example.com/path?q=1", false).is_ok());
7640    }
7641
7642    #[test]
7643    fn url_allows_http_localhost() {
7644        assert!(validate_url("http://localhost:3000", false).is_ok());
7645    }
7646
7647    #[test]
7648    fn url_blocks_file_by_default() {
7649        let err = validate_url("file:///etc/passwd", false).unwrap_err();
7650        assert!(err.contains("file"), "error should mention the file scheme");
7651    }
7652
7653    #[test]
7654    fn url_allows_file_when_opted_in() {
7655        assert!(validate_url("file:///tmp/test.html", true).is_ok());
7656    }
7657
7658    #[test]
7659    fn url_blocks_javascript() {
7660        assert!(validate_url("javascript:alert(1)", false).is_err());
7661    }
7662
7663    #[test]
7664    fn url_blocks_javascript_case_insensitive() {
7665        assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
7666    }
7667
7668    #[test]
7669    fn url_blocks_data_scheme() {
7670        assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
7671    }
7672
7673    #[test]
7674    fn url_blocks_vbscript() {
7675        assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
7676    }
7677
7678    #[test]
7679    fn url_rejects_invalid() {
7680        assert!(validate_url("not a url at all", false).is_err());
7681    }
7682
7683    #[test]
7684    fn url_strips_control_chars() {
7685        // Control characters should be stripped, leaving a valid URL
7686        let input = format!("http://example{}com", '\0');
7687        assert!(validate_url(&input, false).is_ok());
7688    }
7689
7690    // ── CSS color sanitization tests ───────────────────────────────────────
7691
7692    #[test]
7693    fn css_color_valid_hex() {
7694        assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
7695        assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
7696        assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
7697    }
7698
7699    #[test]
7700    fn css_color_valid_rgb() {
7701        assert_eq!(
7702            sanitize_css_color("rgb(255, 0, 0)").unwrap(),
7703            "rgb(255, 0, 0)"
7704        );
7705        assert_eq!(
7706            sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
7707            "rgba(0, 0, 0, 0.5)"
7708        );
7709    }
7710
7711    #[test]
7712    fn css_color_valid_named() {
7713        assert_eq!(sanitize_css_color("red").unwrap(), "red");
7714        assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
7715    }
7716
7717    #[test]
7718    fn css_color_valid_hsl() {
7719        assert_eq!(
7720            sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
7721            "hsl(120, 50%, 50%)"
7722        );
7723    }
7724
7725    #[test]
7726    fn css_color_rejects_too_long() {
7727        let long = "a".repeat(101);
7728        assert!(sanitize_css_color(&long).is_err());
7729    }
7730
7731    #[test]
7732    fn css_color_rejects_backslash_escapes() {
7733        assert!(sanitize_css_color(r"red\00").is_err());
7734        assert!(sanitize_css_color(r"\72\65\64").is_err());
7735    }
7736
7737    #[test]
7738    fn css_color_rejects_url_injection() {
7739        assert!(sanitize_css_color("url(http://evil.com)").is_err());
7740        assert!(sanitize_css_color("URL(http://evil.com)").is_err());
7741    }
7742
7743    #[test]
7744    fn css_color_rejects_expression_injection() {
7745        assert!(sanitize_css_color("expression(alert(1))").is_err());
7746        assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
7747    }
7748
7749    #[test]
7750    fn css_color_rejects_import() {
7751        assert!(sanitize_css_color("@import url(evil.css)").is_err());
7752    }
7753
7754    #[test]
7755    fn css_color_rejects_semicolons_and_braces() {
7756        assert!(sanitize_css_color("red; background: url(evil)").is_err());
7757        assert!(sanitize_css_color("red} body { color: blue").is_err());
7758    }
7759
7760    #[test]
7761    fn css_color_rejects_special_chars() {
7762        assert!(sanitize_css_color("red<script>").is_err());
7763        assert!(sanitize_css_color("red\"onload=alert").is_err());
7764        assert!(sanitize_css_color("red'onclick=alert").is_err());
7765    }
7766
7767    #[test]
7768    fn css_color_trims_whitespace() {
7769        assert_eq!(sanitize_css_color("  red  ").unwrap(), "red");
7770    }
7771
7772    #[test]
7773    fn css_color_empty_string() {
7774        assert_eq!(sanitize_css_color("").unwrap(), "");
7775    }
7776}
7777
7778/// Dispatch-level authorization tests.
7779///
7780/// These exercise the REAL `execute_tool` dispatch path (not just the privacy
7781/// string matrix) to prove that blocked tools/actions actually return
7782/// `tool_disabled` and never reach their handler. This is the negative security
7783/// suite the audit required (Gate #5): the prior tests validated
7784/// `is_tool_enabled(...)` in isolation, which let structural dispatch bypasses
7785/// pass undetected.
7786#[cfg(test)]
7787mod authz_dispatch_tests {
7788    use super::*;
7789    use crate::bridge::WebviewBridge;
7790    use crate::privacy::PrivacyConfig;
7791    use std::collections::{HashMap, HashSet};
7792    use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
7793
7794    /// A bridge whose eval always fails immediately, so an *allowed* action that
7795    /// reaches the bridge returns a non-privacy error fast (no 30s hang), while a
7796    /// *blocked* action is rejected by dispatch before the bridge is ever touched.
7797    struct RejectingBridge;
7798
7799    impl WebviewBridge for RejectingBridge {
7800        fn eval_webview(&self, _label: Option<&str>, _script: &str) -> Result<(), String> {
7801            Err("eval rejected in authz dispatch test".to_string())
7802        }
7803        fn get_window_states(&self, _label: Option<&str>) -> Vec<WindowState> {
7804            Vec::new()
7805        }
7806        fn list_window_labels(&self) -> Vec<String> {
7807            Vec::new()
7808        }
7809        fn get_native_handle(&self, _label: Option<&str>) -> Result<isize, String> {
7810            Err("no handle".to_string())
7811        }
7812        fn manage_window(&self, _label: Option<&str>, _action: &str) -> Result<String, String> {
7813            Err("no window".to_string())
7814        }
7815        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
7816            Ok(())
7817        }
7818        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
7819            Ok(())
7820        }
7821        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
7822            Ok(())
7823        }
7824    }
7825
7826    fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
7827        Arc::new(VictauriState {
7828            event_log: EventLog::new(1000),
7829            registry: CommandRegistry::new(),
7830            port: std::sync::atomic::AtomicU16::new(0),
7831            pending_evals: Arc::new(Mutex::new(HashMap::new())),
7832            recorder: EventRecorder::new(1000),
7833            privacy,
7834            eval_timeout: std::time::Duration::from_millis(100),
7835            shutdown_tx: tokio::sync::watch::channel(false).0,
7836            started_at: std::time::Instant::now(),
7837            tool_invocations: std::sync::atomic::AtomicU64::new(0),
7838            allow_file_navigation: false,
7839            command_timings: crate::introspection::CommandTimings::new(),
7840            fault_registry: crate::introspection::FaultRegistry::new(),
7841            contract_store: crate::introspection::ContractStore::new(),
7842            startup_timeline: crate::introspection::StartupTimeline::new(),
7843            event_bus: crate::introspection::EventBusMonitor::default(),
7844            task_tracker: crate::introspection::TaskTracker::new(),
7845            bridge_ready: std::sync::atomic::AtomicBool::new(true),
7846            bridge_notify: tokio::sync::Notify::new(),
7847            db_search_paths: Vec::new(),
7848            screencast: Arc::new(crate::screencast::Screencast::default()),
7849            probes: crate::introspection::AppStateProbes::default(),
7850            drain_watermarks: crate::introspection::DrainWatermarks::default(),
7851            page_loads: crate::introspection::PageLoads::default(),
7852            server_runtime: std::sync::OnceLock::new(),
7853            backend_logs: std::sync::Arc::new(crate::backend_logs::LogBuffer::default()),
7854        })
7855    }
7856
7857    fn handler(privacy: PrivacyConfig) -> VictauriMcpHandler {
7858        VictauriMcpHandler::new(state_with(privacy), Arc::new(RejectingBridge))
7859    }
7860
7861    /// True iff the result is a privacy/authorization block (vs any other error).
7862    fn is_privacy_blocked(r: &CallToolResult) -> bool {
7863        r.is_error == Some(true)
7864            && r.content.iter().any(|c| {
7865                matches!(c, ContentBlock::Text(t)
7866                    if t.text.contains("disabled by privacy configuration"))
7867            })
7868    }
7869
7870    async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
7871        match h.execute_tool(tool, args).await {
7872            Ok(r) => r,
7873            Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
7874        }
7875    }
7876
7877    /// The action strings a params type's `action` enum accepts, from its JSON schema.
7878    fn schema_actions<T: schemars::JsonSchema>() -> Vec<String> {
7879        fn collect(v: &serde_json::Value, out: &mut Vec<String>) {
7880            if let Some(values) = v.get("enum").and_then(serde_json::Value::as_array) {
7881                out.extend(values.iter().filter_map(|x| x.as_str().map(String::from)));
7882            }
7883            if let Some(c) = v.get("const").and_then(serde_json::Value::as_str) {
7884                out.push(c.to_string());
7885            }
7886            for key in ["oneOf", "anyOf"] {
7887                for sub in v
7888                    .get(key)
7889                    .and_then(serde_json::Value::as_array)
7890                    .into_iter()
7891                    .flatten()
7892                {
7893                    collect(sub, out);
7894                }
7895            }
7896        }
7897        let schema = serde_json::to_value(schemars::schema_for!(T)).unwrap();
7898        let mut action = schema["properties"]["action"].clone();
7899        if let Some(target) = action.get("$ref").and_then(serde_json::Value::as_str) {
7900            let name = target.rsplit('/').next().unwrap();
7901            action = schema["$defs"][name].clone();
7902        }
7903        let mut out = Vec::new();
7904        collect(&action, &mut out);
7905        out
7906    }
7907
7908    /// `resolve_capability` gates an unknown STRING action as the bare tool name,
7909    /// trusting the typed parse to reject it. That holds only if every action the parse
7910    /// ACCEPTS is mapped to its own capability — pinned here from the enums themselves,
7911    /// so a new variant cannot silently fall back to the bare-name gate.
7912    #[test]
7913    fn every_action_variant_has_a_capability() {
7914        let tools: &[(&str, Vec<String>)] = &[
7915            ("interact", schema_actions::<InteractParams>()),
7916            ("input", schema_actions::<InputParams>()),
7917            ("window", schema_actions::<WindowParams>()),
7918            ("storage", schema_actions::<StorageParams>()),
7919            ("navigate", schema_actions::<NavigateParams>()),
7920            ("recording", schema_actions::<RecordingParams>()),
7921            ("inspect", schema_actions::<InspectParams>()),
7922            ("css", schema_actions::<CssParams>()),
7923            ("route", schema_actions::<RouteParams>()),
7924            ("trace", schema_actions::<TraceParams>()),
7925            ("animation", schema_actions::<AnimationParams>()),
7926            ("logs", schema_actions::<LogsParams>()),
7927            ("introspect", schema_actions::<IntrospectParams>()),
7928            ("fault", schema_actions::<FaultParams>()),
7929            ("explain", schema_actions::<ExplainParams>()),
7930        ];
7931        for (tool, actions) in tools {
7932            assert!(
7933                !actions.is_empty(),
7934                "{tool}: no actions read from its schema"
7935            );
7936            for action in actions {
7937                assert!(
7938                    authz::action_capability(tool, action).is_some(),
7939                    "{tool}.{action} is accepted by the parser but has no capability"
7940                );
7941            }
7942        }
7943    }
7944
7945    /// The names `disable_tools` validates against are the live tool surface: every
7946    /// registered tool is either a compound tool or in `STANDALONE_TOOLS`, and nothing else.
7947    #[test]
7948    fn disable_tools_name_list_matches_the_live_tools() {
7949        let mut live: Vec<String> = VictauriMcpHandler::tool_router()
7950            .list_all()
7951            .into_iter()
7952            .map(|t| t.name.to_string())
7953            .filter(|n| !authz::is_compound_tool(n))
7954            .collect();
7955        let mut listed: Vec<String> = authz::STANDALONE_TOOLS
7956            .iter()
7957            .map(|s| (*s).to_string())
7958            .collect();
7959        live.sort_unstable();
7960        listed.sort_unstable();
7961        assert_eq!(live, listed);
7962        for t in VictauriMcpHandler::tool_router().list_all() {
7963            assert!(authz::is_known_name(&t.name), "{}", t.name);
7964        }
7965    }
7966
7967    /// Audit N1: `{"action": {"go_to": null}}` (a tag-shaped enum serde accepts) and a
7968    /// positional array body used to be gated as the bare tool name, which the Test
7969    /// profile allows for `navigate` — the handler then parsed and ran `go_to`. Both
7970    /// shapes must now be refused as invalid params before any handler runs, in every
7971    /// profile (`FullControl` with the action disabled is the other half of the bypass).
7972    #[tokio::test]
7973    async fn non_string_action_cannot_slip_past_the_gate() {
7974        let mut full_minus_go_to = PrivacyConfig::default();
7975        full_minus_go_to
7976            .disabled_tools
7977            .insert("navigate.go_to".to_string());
7978        for privacy in [crate::privacy::test_privacy_config(), full_minus_go_to] {
7979            let h = handler(privacy);
7980            for args in [
7981                serde_json::json!({"action": {"go_to": null}, "url": "https://evil.example"}),
7982                serde_json::json!(["go_to", "https://evil.example", null, null, null, null]),
7983                serde_json::json!({"action": 0, "url": "https://evil.example"}),
7984            ] {
7985                match h.execute_tool("navigate", args.clone()).await {
7986                    Err(rest::ToolCallError::InvalidParams(_)) => {}
7987                    Ok(r) => panic!("{args} reached dispatch: {:?}", r.content),
7988                    Err(rest::ToolCallError::UnknownTool(t)) => panic!("{args}: unknown tool {t}"),
7989                }
7990            }
7991        }
7992    }
7993
7994    // ── Observe profile: every mutation/eval/compound-action must be blocked ──
7995
7996    #[tokio::test]
7997    async fn observe_blocks_mutations_and_eval_through_dispatch() {
7998        let h = handler(crate::privacy::observe_privacy_config());
7999        let blocked: &[(&str, serde_json::Value)] = &[
8000            ("eval_js", serde_json::json!({"code": "1"})),
8001            (
8002                "wait_for",
8003                serde_json::json!({"condition": "expression", "value": "true"}),
8004            ),
8005            ("screenshot", serde_json::json!({})),
8006            ("invoke_command", serde_json::json!({"command": "greet"})),
8007            ("verify_state", serde_json::json!({"frontend_expr": "1"})),
8008            (
8009                "assert_semantic",
8010                serde_json::json!({"expression": "1", "condition": "truthy"}),
8011            ),
8012            (
8013                "interact",
8014                serde_json::json!({"action": "click", "ref_id": "e1"}),
8015            ),
8016            (
8017                "input",
8018                serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
8019            ),
8020            (
8021                "storage",
8022                serde_json::json!({"action": "set", "key": "k", "value": "v"}),
8023            ),
8024            (
8025                "storage",
8026                serde_json::json!({"action": "delete", "key": "k"}),
8027            ),
8028            (
8029                "window",
8030                serde_json::json!({"action": "manage", "manage_action": "close"}),
8031            ),
8032            (
8033                "window",
8034                serde_json::json!({"action": "set_title", "title": "x"}),
8035            ),
8036            (
8037                "navigate",
8038                serde_json::json!({"action": "go_to", "url": "https://e.com"}),
8039            ),
8040            (
8041                "css",
8042                serde_json::json!({"action": "inject", "css": "body{}"}),
8043            ),
8044            ("route", serde_json::json!({"action": "clear_all"})),
8045            ("recording", serde_json::json!({"action": "start"})),
8046            ("recording", serde_json::json!({"action": "replay"})),
8047            ("logs", serde_json::json!({"action": "clear"})),
8048            (
8049                "fault",
8050                serde_json::json!({"action": "inject", "command": "x", "fault_type": "error"}),
8051            ),
8052            (
8053                "introspect",
8054                serde_json::json!({"action": "command_timings"}),
8055            ),
8056        ];
8057        for (tool, args) in blocked {
8058            let r = call(&h, tool, args.clone()).await;
8059            assert!(
8060                is_privacy_blocked(&r),
8061                "Observe must block {tool} {args} at dispatch, got: {:?}",
8062                r.content
8063            );
8064        }
8065    }
8066
8067    #[tokio::test]
8068    async fn observe_allows_read_only_through_dispatch() {
8069        let h = handler(crate::privacy::observe_privacy_config());
8070        // These reads must NOT be privacy-blocked (they may fail for other reasons
8071        // against the rejecting bridge, but never with a privacy block).
8072        let allowed: &[(&str, serde_json::Value)] = &[
8073            ("get_registry", serde_json::json!({})),
8074            ("get_memory_stats", serde_json::json!({})),
8075            ("window", serde_json::json!({"action": "list"})),
8076            ("logs", serde_json::json!({"action": "ipc"})),
8077            (
8078                "inspect",
8079                serde_json::json!({"action": "get_styles", "ref_id": "e1"}),
8080            ),
8081        ];
8082        for (tool, args) in allowed {
8083            let r = call(&h, tool, args.clone()).await;
8084            assert!(
8085                !is_privacy_blocked(&r),
8086                "Observe must allow {tool} {args} at dispatch (blocked unexpectedly)"
8087            );
8088        }
8089    }
8090
8091    // ── Test profile: interactions allowed, eval/replay/route blocked ─────────
8092
8093    #[tokio::test]
8094    async fn test_profile_dispatch_boundaries() {
8095        let h = handler(crate::privacy::test_privacy_config());
8096        // Allowed in Test:
8097        for (tool, args) in [
8098            (
8099                "interact",
8100                serde_json::json!({"action": "click", "ref_id": "e1"}),
8101            ),
8102            (
8103                "input",
8104                serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
8105            ),
8106            (
8107                "storage",
8108                serde_json::json!({"action": "set", "key": "k", "value": "v"}),
8109            ),
8110            ("navigate", serde_json::json!({"action": "go_back"})),
8111            ("recording", serde_json::json!({"action": "start"})),
8112            ("logs", serde_json::json!({"action": "clear"})),
8113        ] {
8114            let r = call(&h, tool, args.clone()).await;
8115            assert!(!is_privacy_blocked(&r), "Test must allow {tool} {args}");
8116        }
8117        // Blocked in Test (arbitrary eval, navigation mutation, replay, FullControl tools):
8118        for (tool, args) in [
8119            ("eval_js", serde_json::json!({"code": "1"})),
8120            (
8121                "wait_for",
8122                serde_json::json!({"condition": "expression", "value": "true"}),
8123            ),
8124            ("verify_state", serde_json::json!({"frontend_expr": "1"})),
8125            (
8126                "navigate",
8127                serde_json::json!({"action": "go_to", "url": "https://e.com"}),
8128            ),
8129            ("recording", serde_json::json!({"action": "replay"})),
8130            (
8131                "route",
8132                serde_json::json!({"action": "add", "pattern": "x"}),
8133            ),
8134            ("css", serde_json::json!({"action": "inject", "css": "x"})),
8135            (
8136                "window",
8137                serde_json::json!({"action": "set_title", "title": "x"}),
8138            ),
8139        ] {
8140            let r = call(&h, tool, args.clone()).await;
8141            assert!(is_privacy_blocked(&r), "Test must block {tool} {args}");
8142        }
8143    }
8144
8145    // ── disabled_tools: bare-name disable covers all of a compound tool's
8146    //    actions, and per-action disable is honored even when the handler
8147    //    historically did not check it (the route.clear bypass). ──────────────
8148
8149    #[tokio::test]
8150    async fn disabling_bare_compound_tool_blocks_all_actions() {
8151        let cfg = PrivacyConfig {
8152            disabled_tools: HashSet::from(["recording".to_string()]),
8153            ..Default::default()
8154        }; // FullControl with the whole `recording` tool disabled
8155        let h = handler(cfg);
8156        for action in ["start", "stop", "replay", "import", "export"] {
8157            let r = call(&h, "recording", serde_json::json!({"action": action})).await;
8158            assert!(
8159                is_privacy_blocked(&r),
8160                "disabling bare `recording` must block recording.{action}"
8161            );
8162        }
8163    }
8164
8165    #[tokio::test]
8166    async fn disabling_specific_action_is_honored_at_dispatch() {
8167        // The historical bypass: `route.clear`'s handler had no per-action check,
8168        // so a `disabled_tools` entry for it was silently ignored. The central
8169        // gate now enforces it.
8170        let cfg = PrivacyConfig {
8171            disabled_tools: HashSet::from([
8172                "route.clear".to_string(),
8173                "route.clear_all".to_string(),
8174            ]),
8175            ..Default::default()
8176        }; // FullControl: everything else allowed
8177        let h = handler(cfg);
8178
8179        let blocked = call(&h, "route", serde_json::json!({"action": "clear", "id": 1})).await;
8180        assert!(is_privacy_blocked(&blocked), "route.clear must be blocked");
8181        let blocked_all = call(&h, "route", serde_json::json!({"action": "clear_all"})).await;
8182        assert!(
8183            is_privacy_blocked(&blocked_all),
8184            "route.clear_all must be blocked"
8185        );
8186
8187        // A sibling action the operator did NOT disable is still reachable.
8188        let allowed = call(&h, "route", serde_json::json!({"action": "list"})).await;
8189        assert!(
8190            !is_privacy_blocked(&allowed),
8191            "route.list must remain allowed"
8192        );
8193    }
8194
8195    /// R4-NET2: `animation scrub` with `capture=true` takes native window screenshots, so an
8196    /// operator who disabled `screenshot` must not get pixels through it (`trace` already
8197    /// required both). Without capture, scrub is a plain page read and stays allowed.
8198    #[tokio::test]
8199    async fn animation_scrub_capture_requires_the_screenshot_tool() {
8200        let cfg = PrivacyConfig {
8201            disabled_tools: HashSet::from(["screenshot".to_string()]),
8202            ..Default::default()
8203        };
8204        let h = handler(cfg);
8205        let r = call(
8206            &h,
8207            "animation",
8208            serde_json::json!({"action": "scrub", "selector": "#toast", "capture": true}),
8209        )
8210        .await;
8211        assert!(
8212            is_privacy_blocked(&r),
8213            "scrub capture must be refused while `screenshot` is disabled, got: {:?}",
8214            r.content
8215        );
8216        assert!(
8217            r.content
8218                .iter()
8219                .any(|c| matches!(c, ContentBlock::Text(t) if t.text.contains("screenshot"))),
8220            "the refusal must name the screenshot tool: {:?}",
8221            r.content
8222        );
8223        let r = call(
8224            &h,
8225            "animation",
8226            serde_json::json!({"action": "scrub", "selector": "#toast"}),
8227        )
8228        .await;
8229        assert!(!is_privacy_blocked(&r), "scrub without capture is allowed");
8230    }
8231
8232    /// R4-NET4: the action spelling of an `inspect` capability disables it at dispatch.
8233    #[tokio::test]
8234    async fn disabling_an_inspect_action_by_its_action_name_is_honored() {
8235        let cfg = PrivacyConfig {
8236            disabled_tools: HashSet::from(["inspect.get_styles".to_string()]),
8237            ..Default::default()
8238        };
8239        let h = handler(cfg);
8240        let r = call(
8241            &h,
8242            "inspect",
8243            serde_json::json!({"action": "get_styles", "ref_id": "e1"}),
8244        )
8245        .await;
8246        assert!(
8247            is_privacy_blocked(&r),
8248            "inspect.get_styles must be blocked, got: {:?}",
8249            r.content
8250        );
8251        let r = call(
8252            &h,
8253            "inspect",
8254            serde_json::json!({"action": "get_bounding_boxes", "ref_ids": ["e1"]}),
8255        )
8256        .await;
8257        assert!(!is_privacy_blocked(&r), "a sibling action stays allowed");
8258    }
8259
8260    // Command-policy enforcement on invoke paths (A1/A2) and resource gating (B1)
8261    // are covered with side-effect detection (a bridge that records actual invokes)
8262    // in the `command_policy_dispatch_tests` module below — that proves the blocked
8263    // command never reaches the bridge, not merely that an error string is returned.
8264
8265    #[tokio::test]
8266    async fn full_control_allows_everything_at_dispatch() {
8267        let h = handler(PrivacyConfig::default());
8268        for (tool, args) in [
8269            ("recording", serde_json::json!({"action": "replay"})),
8270            ("route", serde_json::json!({"action": "clear_all"})),
8271            ("eval_js", serde_json::json!({"code": "1"})),
8272            ("fault", serde_json::json!({"action": "list"})),
8273        ] {
8274            let r = call(&h, tool, args.clone()).await;
8275            assert!(
8276                !is_privacy_blocked(&r),
8277                "FullControl must allow {tool} {args}"
8278            );
8279        }
8280    }
8281}
8282
8283/// Command-policy enforcement on EVERY command-invoking path (audit #30/#31, triage A1/A2).
8284///
8285/// The prior privacy suite validated the permission-string matrix — `is_tool_enabled("x")`
8286/// in isolation — which let structural dispatch bypasses pass undetected (the audit's
8287/// central criticism: "tests validate the STRING MATRIX, not actual dispatch behavior").
8288///
8289/// These tests instead drive the REAL dispatcher with a bridge that records every script
8290/// handed to `eval_webview`, and assert the dangerous **side effect** — the
8291/// `__TAURI_INTERNALS__.invoke(<command>)` script — is NEVER emitted when the command is on
8292/// the operator's blocklist, on each path that invokes commands OUTSIDE `invoke_command`:
8293/// `recording.replay`, `recording.import` + `replay`, `introspect.contract_record`, and
8294/// `introspect.contract_check`. Each has a positive control proving an *allowed* command IS
8295/// invoked (so a blanket-block can't make the negative test pass vacuously).
8296#[cfg(test)]
8297mod command_policy_dispatch_tests {
8298    use super::*;
8299    use crate::bridge::WebviewBridge;
8300    use crate::privacy::PrivacyConfig;
8301    use serde_json::json;
8302    use std::collections::{HashMap, HashSet};
8303    use std::sync::Mutex as StdMutex;
8304    use victauri_core::{
8305        AppEvent, CommandRegistry, EventLog, EventRecorder, IpcCall, IpcResult, RecordedEvent,
8306        RecordedSession, WindowState,
8307    };
8308
8309    /// A bridge that RECORDS every script passed to `eval_webview` (so a test can assert a
8310    /// blocklisted command's invoke was never emitted) then fails the eval fast — an allowed
8311    /// command is observably *attempted* without hanging on a callback that never arrives.
8312    ///
8313    /// When constructed via [`RecordingBridge::answering`] it also resolves the pre-eval
8314    /// liveness probe, simulating a healthy webview so an ALLOWED command's invoke actually
8315    /// reaches the bridge. Default-constructed bridges leave the probe unanswered — which is
8316    /// fine for negative tests, since a blocked command is rejected at the privacy gate
8317    /// *before* any eval (and thus never probes).
8318    #[derive(Clone, Default)]
8319    struct RecordingBridge {
8320        scripts: Arc<StdMutex<Vec<String>>>,
8321        pending_evals: Option<crate::PendingCallbacks>,
8322        /// The nonce of the page currently loaded (reported by the liveness probe).
8323        page_nonce: Arc<StdMutex<Option<String>>>,
8324        /// When set, the eval wrapper script is answered with this callback body.
8325        eval_answer: Arc<StdMutex<Option<String>>>,
8326        /// How long after the wrapper is delivered its answer arrives (code that takes a while).
8327        eval_answer_delay: Arc<StdMutex<std::time::Duration>>,
8328        /// Every trusted (OS-level) input delivered, e.g. `click 50,26` / `type hi` / `key Enter`.
8329        natives: Arc<StdMutex<Vec<String>>>,
8330        /// While set, the liveness probe is never answered (a busy or wedged UI thread).
8331        probe_silent: Arc<AtomicBool>,
8332    }
8333
8334    /// Extract the 36-char eval id from a probe script of the form `…id:"<uuid>"…`.
8335    fn extract_probe_id(script: &str) -> Option<String> {
8336        let start = script.find("id:\"")? + 4;
8337        script.get(start..start + 36).map(str::to_string)
8338    }
8339
8340    /// Extract the eval id from the eval wrapper script (`const __vic = { id: "<uuid>", …`).
8341    fn extract_wrapper_id(script: &str) -> Option<String> {
8342        let start = script.find("__vic = { id: \"")? + 15;
8343        script.get(start..start + 36).map(str::to_string)
8344    }
8345
8346    impl RecordingBridge {
8347        /// A recording bridge that answers the liveness probe with the state's pending-evals
8348        /// map, so a permitted command's eval proceeds past the probe and is observably
8349        /// injected.
8350        fn answering(pending_evals: crate::PendingCallbacks) -> Self {
8351            Self {
8352                pending_evals: Some(pending_evals),
8353                ..Self::default()
8354            }
8355        }
8356
8357        /// Like [`answering`](Self::answering), in a page whose nonce is `nonce`.
8358        fn in_page(pending_evals: crate::PendingCallbacks, nonce: &str) -> Self {
8359            let b = Self::answering(pending_evals);
8360            b.load_page(nonce);
8361            b
8362        }
8363
8364        /// The window now shows a page with this nonce (what the liveness probe reports).
8365        fn load_page(&self, nonce: &str) {
8366            *self
8367                .page_nonce
8368                .lock()
8369                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(nonce.to_string());
8370        }
8371
8372        fn answer_evals_with(&self, body: &str) {
8373            *self
8374                .eval_answer
8375                .lock()
8376                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(body.to_string());
8377        }
8378
8379        /// Like [`answer_evals_with`](Self::answer_evals_with), arriving `delay` after the code
8380        /// was delivered.
8381        fn answer_evals_after(&self, body: &str, delay: std::time::Duration) {
8382            self.answer_evals_with(body);
8383            *self
8384                .eval_answer_delay
8385                .lock()
8386                .unwrap_or_else(std::sync::PoisonError::into_inner) = delay;
8387        }
8388
8389        fn natives(&self) -> Vec<String> {
8390            self.natives
8391                .lock()
8392                .unwrap_or_else(std::sync::PoisonError::into_inner)
8393                .clone()
8394        }
8395
8396        fn record_native(&self, what: String) -> Result<(), String> {
8397            self.natives
8398                .lock()
8399                .unwrap_or_else(std::sync::PoisonError::into_inner)
8400                .push(what);
8401            Ok(())
8402        }
8403
8404        /// True iff any recorded eval script invoked `command` via the Tauri IPC bridge.
8405        fn invoked(&self, command: &str) -> bool {
8406            let needle = format!("invoke({}", js_string(command));
8407            self.scripts
8408                .lock()
8409                .unwrap_or_else(std::sync::PoisonError::into_inner)
8410                .iter()
8411                .any(|s| s.contains(&needle))
8412        }
8413    }
8414
8415    impl WebviewBridge for RecordingBridge {
8416        fn eval_webview(&self, _label: Option<&str>, script: &str) -> Result<(), String> {
8417            self.scripts
8418                .lock()
8419                .unwrap_or_else(std::sync::PoisonError::into_inner)
8420                .push(script.to_string());
8421            // If wired with a pending-evals map, answer the pre-eval liveness probe
8422            // (simulating a healthy webview) so the real eval proceeds past it. The
8423            // real eval is still left unanswered, so it times out fast at the 100ms
8424            // test `eval_timeout` — we only care WHICH scripts reached the bridge,
8425            // never the eval's return value.
8426            let mut delay = std::time::Duration::ZERO;
8427            let answer = if script.contains("probe_ok") {
8428                if self.probe_silent.load(Ordering::SeqCst) {
8429                    return Ok(());
8430                }
8431                extract_probe_id(script).map(|id| {
8432                    let nonce = self
8433                        .page_nonce
8434                        .lock()
8435                        .unwrap_or_else(std::sync::PoisonError::into_inner)
8436                        .clone();
8437                    let body = nonce.map_or_else(
8438                        || "\"probe_ok\"".to_string(),
8439                        |n| format!("\"probe_ok:{n}\""),
8440                    );
8441                    (id, body)
8442                })
8443            } else {
8444                let body = self
8445                    .eval_answer
8446                    .lock()
8447                    .unwrap_or_else(std::sync::PoisonError::into_inner)
8448                    .clone();
8449                delay = *self
8450                    .eval_answer_delay
8451                    .lock()
8452                    .unwrap_or_else(std::sync::PoisonError::into_inner);
8453                extract_wrapper_id(script).zip(body)
8454            };
8455            if let (Some(pending), Some((id, body))) = (&self.pending_evals, answer) {
8456                let pending = pending.clone();
8457                std::thread::spawn(move || {
8458                    std::thread::sleep(delay);
8459                    let mut map = pending.blocking_lock();
8460                    if let Some(tx) = map.remove(&id) {
8461                        let _ = tx.send(body);
8462                    }
8463                });
8464            }
8465            // Return Ok so `eval_with_return` injects BOTH its watchdog and the
8466            // user-code script (it bails on the first Err).
8467            Ok(())
8468        }
8469        fn native_click(&self, _l: Option<&str>, x: f64, y: f64) -> Result<(), String> {
8470            self.record_native(format!("click {x},{y}"))
8471        }
8472        fn native_type_text(&self, _l: Option<&str>, text: &str) -> Result<(), String> {
8473            self.record_native(format!("type {text}"))
8474        }
8475        fn native_key(&self, _l: Option<&str>, key: &str) -> Result<(), String> {
8476            self.record_native(format!("key {key}"))
8477        }
8478        fn get_window_states(&self, _l: Option<&str>) -> Vec<WindowState> {
8479            Vec::new()
8480        }
8481        fn list_window_labels(&self) -> Vec<String> {
8482            Vec::new()
8483        }
8484        fn get_native_handle(&self, _l: Option<&str>) -> Result<isize, String> {
8485            Err("no handle".to_string())
8486        }
8487        fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
8488            Err("no window".to_string())
8489        }
8490        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
8491            Ok(())
8492        }
8493        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
8494            Ok(())
8495        }
8496        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
8497            Ok(())
8498        }
8499    }
8500
8501    fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
8502        Arc::new(VictauriState {
8503            event_log: EventLog::new(1000),
8504            registry: CommandRegistry::new(),
8505            port: std::sync::atomic::AtomicU16::new(0),
8506            pending_evals: Arc::new(Mutex::new(HashMap::new())),
8507            recorder: EventRecorder::new(1000),
8508            privacy,
8509            eval_timeout: std::time::Duration::from_millis(100),
8510            shutdown_tx: tokio::sync::watch::channel(false).0,
8511            started_at: std::time::Instant::now(),
8512            tool_invocations: std::sync::atomic::AtomicU64::new(0),
8513            allow_file_navigation: false,
8514            command_timings: crate::introspection::CommandTimings::new(),
8515            fault_registry: crate::introspection::FaultRegistry::new(),
8516            contract_store: crate::introspection::ContractStore::new(),
8517            startup_timeline: crate::introspection::StartupTimeline::new(),
8518            event_bus: crate::introspection::EventBusMonitor::default(),
8519            task_tracker: crate::introspection::TaskTracker::new(),
8520            bridge_ready: std::sync::atomic::AtomicBool::new(true),
8521            bridge_notify: tokio::sync::Notify::new(),
8522            db_search_paths: Vec::new(),
8523            screencast: Arc::new(crate::screencast::Screencast::default()),
8524            probes: crate::introspection::AppStateProbes::default(),
8525            drain_watermarks: crate::introspection::DrainWatermarks::default(),
8526            page_loads: crate::introspection::PageLoads::default(),
8527            server_runtime: std::sync::OnceLock::new(),
8528            backend_logs: std::sync::Arc::new(crate::backend_logs::LogBuffer::default()),
8529        })
8530    }
8531
8532    // FullControl, except the named commands are blocklisted — exactly the scenario
8533    // the audit flagged: an operator who trusts `command_blocklist` to stop a
8534    // dangerous command.
8535    fn blocking(cmds: &[&str]) -> PrivacyConfig {
8536        PrivacyConfig {
8537            command_blocklist: cmds.iter().map(|s| (*s).to_string()).collect(),
8538            ..Default::default()
8539        }
8540    }
8541
8542    fn ipc_event(command: &str) -> AppEvent {
8543        AppEvent::Ipc(IpcCall::new(
8544            format!("c-{command}"),
8545            command.to_string(),
8546            chrono::Utc::now(),
8547            IpcResult::Ok(json!(true)),
8548            Some(1),
8549            0,
8550            "main".to_string(),
8551        ))
8552    }
8553
8554    fn result_text(r: &CallToolResult) -> String {
8555        r.content
8556            .iter()
8557            .filter_map(|c| match c {
8558                ContentBlock::Text(t) => Some(t.text.clone()),
8559                _ => None,
8560            })
8561            .collect::<Vec<_>>()
8562            .join("\n")
8563    }
8564
8565    async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
8566        match h.execute_tool(tool, args).await {
8567            Ok(r) => r,
8568            Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
8569        }
8570    }
8571
8572    // ── introspect event_bus output cap (VIC-4) ──────────────────────────────
8573    #[tokio::test]
8574    async fn event_bus_caps_output_to_limit() {
8575        // The full buffers can be tens of thousands of events (megabytes); the action must cap
8576        // output (default 100, newest first) and still report the true total + a truncated flag.
8577        use crate::introspection::CapturedTauriEvent;
8578        let state = state_with(PrivacyConfig::default());
8579        for i in 0..150 {
8580            state.event_bus.push(CapturedTauriEvent {
8581                name: format!("evt-{i}"),
8582                payload: "{}".to_string(),
8583                timestamp: chrono::Utc::now().to_rfc3339(),
8584            });
8585        }
8586        let h = VictauriMcpHandler::new(state, Arc::new(RecordingBridge::default()));
8587
8588        // Default limit (100).
8589        let r = call(&h, "introspect", json!({"action": "event_bus"})).await;
8590        let v: serde_json::Value = serde_json::from_str(&result_text(&r)).unwrap();
8591        assert_eq!(
8592            v["tauri_events"]["count"], 150,
8593            "true total must be reported"
8594        );
8595        assert_eq!(v["tauri_events"]["returned"], 100, "default cap is 100");
8596        assert_eq!(v["tauri_events"]["truncated"], true);
8597        assert_eq!(v["tauri_events"]["events"].as_array().unwrap().len(), 100);
8598
8599        // Explicit smaller limit (passed via the generic `args` object).
8600        let r = call(
8601            &h,
8602            "introspect",
8603            json!({"action": "event_bus", "args": {"limit": 10}}),
8604        )
8605        .await;
8606        let v: serde_json::Value = serde_json::from_str(&result_text(&r)).unwrap();
8607        assert_eq!(v["tauri_events"]["returned"], 10);
8608        assert_eq!(v["tauri_events"]["events"].as_array().unwrap().len(), 10);
8609    }
8610
8611    // ── recording.replay (audit #30/#31, A1) ─────────────────────────────────
8612
8613    #[tokio::test]
8614    async fn replay_never_invokes_a_blocklisted_command() {
8615        let bridge = RecordingBridge::default();
8616        let state = state_with(blocking(&["delete_account"]));
8617        state.recorder.start("s1".to_string()).unwrap();
8618        state.recorder.record_event(ipc_event("delete_account"));
8619        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8620
8621        let r = call(&h, "recording", json!({"action": "replay"})).await;
8622
8623        assert!(
8624            !bridge.invoked("delete_account"),
8625            "SIDE-EFFECT LEAK: replay handed a blocklisted command's invoke to the bridge (audit #30/#31)"
8626        );
8627        assert!(
8628            result_text(&r).contains("blocked"),
8629            "replay should report the command as blocked, got: {}",
8630            result_text(&r)
8631        );
8632    }
8633
8634    #[tokio::test]
8635    async fn replay_does_invoke_an_allowed_command() {
8636        // Positive control: proves the negative test isn't vacuous (the path really
8637        // reaches the bridge for a permitted command).
8638        let state = state_with(PrivacyConfig::default());
8639        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8640        state.recorder.start("s1".to_string()).unwrap();
8641        state.recorder.record_event(ipc_event("greet"));
8642        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8643
8644        let _ = call(&h, "recording", json!({"action": "replay"})).await;
8645
8646        assert!(
8647            bridge.invoked("greet"),
8648            "positive control failed: an ALLOWED command was not invoked, so the negative test proves nothing"
8649        );
8650    }
8651
8652    #[tokio::test]
8653    async fn imported_session_cannot_invoke_a_blocklisted_command() {
8654        // audit #31: a crafted session handed to an agent ("replay this to reproduce")
8655        // must not become arbitrary command invocation.
8656        let bridge = RecordingBridge::default();
8657        let state = state_with(blocking(&["wipe_database"]));
8658        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8659
8660        let session = RecordedSession::new(
8661            "poisoned".to_string(),
8662            chrono::Utc::now(),
8663            vec![RecordedEvent::new(
8664                0,
8665                chrono::Utc::now(),
8666                ipc_event("wipe_database"),
8667            )],
8668            Vec::new(),
8669        );
8670        let session_json = serde_json::to_string(&session).unwrap();
8671
8672        let imp = call(
8673            &h,
8674            "recording",
8675            json!({"action": "import", "session_json": session_json}),
8676        )
8677        .await;
8678        assert_ne!(
8679            imp.is_error,
8680            Some(true),
8681            "import itself should succeed: {}",
8682            result_text(&imp)
8683        );
8684
8685        let r = call(&h, "recording", json!({"action": "replay"})).await;
8686        assert!(
8687            !bridge.invoked("wipe_database"),
8688            "SIDE-EFFECT LEAK: an imported session replayed a blocklisted command (audit #31)"
8689        );
8690        assert!(result_text(&r).contains("blocked"));
8691    }
8692
8693    #[tokio::test]
8694    async fn replay_skips_calls_it_cannot_reproduce_faithfully() {
8695        // Recordings do not capture arguments: re-invoking `delete_todo({id:3})` as
8696        // `delete_todo()` is guaranteed wrong, and re-running a failed/pending call reproduces
8697        // nothing. Only successful no-arg calls are replayed.
8698        let state = state_with(PrivacyConfig::default());
8699        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8700        state.recorder.start("s".to_string()).unwrap();
8701        let AppEvent::Ipc(mut with_args) = ipc_event("delete_todo") else {
8702            unreachable!()
8703        };
8704        with_args.arg_size_bytes = 8;
8705        state.recorder.record_event(AppEvent::Ipc(with_args));
8706        let AppEvent::Ipc(mut failed) = ipc_event("flaky") else {
8707            unreachable!()
8708        };
8709        failed.result = IpcResult::Err("boom".to_string());
8710        state.recorder.record_event(AppEvent::Ipc(failed));
8711        state.recorder.record_event(ipc_event("get_counter"));
8712        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8713
8714        let r = call(&h, "recording", json!({"action": "replay"})).await;
8715        let text = result_text(&r);
8716        assert!(
8717            !bridge.invoked("delete_todo"),
8718            "a call with args must not be replayed"
8719        );
8720        assert!(
8721            !bridge.invoked("flaky"),
8722            "a failed call must not be replayed"
8723        );
8724        assert!(
8725            bridge.invoked("get_counter"),
8726            "a successful no-arg call is replayed"
8727        );
8728        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
8729        assert_eq!(v["skipped"], 2, "{text}");
8730        assert_eq!(v["replayed"], 1, "{text}");
8731    }
8732
8733    /// A state whose eval timeout is long enough that only the window/shutdown watch can end
8734    /// an unanswered eval quickly.
8735    fn slow_eval_state() -> Arc<VictauriState> {
8736        let Ok(mut s) = Arc::try_unwrap(state_with(PrivacyConfig::default())) else {
8737            unreachable!("fresh Arc has one owner")
8738        };
8739        s.eval_timeout = std::time::Duration::from_secs(20);
8740        Arc::new(s)
8741    }
8742
8743    #[tokio::test]
8744    async fn eval_reports_a_window_closed_mid_call_instead_of_timing_out() {
8745        // The RecordingBridge answers the liveness probe, never the user code, and lists NO
8746        // windows — i.e. the target window is gone while the call is in flight (what a
8747        // command that closes its own window produces).
8748        let state = slow_eval_state();
8749        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8750        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
8751        let started = std::time::Instant::now();
8752        let r = call(
8753            &h,
8754            "eval_js",
8755            json!({"code": "await new Promise(() => {})", "webview_label": "popup"}),
8756        )
8757        .await;
8758        let text = result_text(&r);
8759        assert_eq!(r.is_error, Some(true), "{text}");
8760        assert!(
8761            text.contains("was closed while the call was in flight"),
8762            "{text}"
8763        );
8764        assert!(
8765            started.elapsed() < std::time::Duration::from_secs(5),
8766            "must not wait out the 20s eval timeout (took {:?})",
8767            started.elapsed()
8768        );
8769    }
8770
8771    /// A bridge whose UI thread never answers a window listing (a busy/wedged UI), but which
8772    /// otherwise behaves like `RecordingBridge`.
8773    struct WedgedListingBridge(RecordingBridge);
8774
8775    impl WebviewBridge for WedgedListingBridge {
8776        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
8777            self.0.eval_webview(label, script)
8778        }
8779        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
8780            self.0.get_window_states(label)
8781        }
8782        fn list_window_labels(&self) -> Vec<String> {
8783            Vec::new()
8784        }
8785        fn try_list_window_labels(&self) -> Result<Vec<String>, String> {
8786            Err("list_window_labels did not complete on the main thread: timed out".to_string())
8787        }
8788        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
8789            self.0.get_native_handle(label)
8790        }
8791        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
8792            self.0.manage_window(label, action)
8793        }
8794        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
8795            self.0.resize_window(label, w, h)
8796        }
8797        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
8798            self.0.move_window(label, x, y)
8799        }
8800        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
8801            self.0.set_window_title(label, title)
8802        }
8803    }
8804
8805    #[tokio::test]
8806    async fn page_originated_evals_cannot_starve_agent_evals() {
8807        // Red-team: page JS can call victauri_eval_js; with one shared pool it could park
8808        // never-resolving evals in all 100 slots and fail every agent eval.
8809        let state = state_with(PrivacyConfig::default());
8810        let mut held = Vec::new();
8811        for i in 0..crate::tools::MAX_PAGE_PENDING_EVALS {
8812            let (tx, rx) = tokio::sync::oneshot::channel();
8813            // Spread over windows so the page-wide budget, not a window's, is what fills up.
8814            let label = format!("w{}", i % 5);
8815            let slot = crate::tools::reserve_page_eval(&state, &label, tx)
8816                .await
8817                .unwrap();
8818            held.push((slot, rx));
8819        }
8820        let (tx, _rx) = tokio::sync::oneshot::channel();
8821        let over = crate::tools::reserve_page_eval(&state, "w9", tx).await;
8822        assert!(over.err().unwrap().contains("page-originated"));
8823        // The agent path still has room.
8824        let bridge = RecordingBridge::answering(state.pending_evals.clone());
8825        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
8826        let (tx, _rx2) = tokio::sync::oneshot::channel();
8827        assert!(h.reserve_pending("agent-1", tx).await.is_ok());
8828        drop(held);
8829    }
8830
8831    #[tokio::test]
8832    async fn one_window_cannot_starve_another_windows_page_evals() {
8833        let state = state_with(PrivacyConfig::default());
8834        let mut held = Vec::new();
8835        let mut refused = None;
8836        // Window "a" (whose label is a prefix of "a:b") fills its own budget and no more.
8837        for _ in 0..=crate::tools::MAX_PAGE_PENDING_EVALS_PER_WINDOW {
8838            let (tx, rx) = tokio::sync::oneshot::channel();
8839            match crate::tools::reserve_page_eval(&state, "a", tx).await {
8840                Ok(slot) => held.push((slot, rx)),
8841                Err(e) => refused = Some(e),
8842            }
8843        }
8844        assert_eq!(held.len(), crate::tools::MAX_PAGE_PENDING_EVALS_PER_WINDOW);
8845        assert!(refused.unwrap().contains("window 'a'"));
8846        let (tx, _rx) = tokio::sync::oneshot::channel();
8847        let other = crate::tools::reserve_page_eval(&state, "a:b", tx).await;
8848        assert!(
8849            other.is_ok(),
8850            "window 'a:b' was starved by window 'a': {:?}",
8851            other.as_ref().err()
8852        );
8853        // Dropping the slots releases them.
8854        drop(held);
8855        drop(other);
8856        assert!(state.pending_evals.lock().await.is_empty());
8857    }
8858
8859    #[tokio::test]
8860    async fn a_busy_ui_is_not_mistaken_for_a_closed_window() {
8861        // Red-team finding: a main-thread listing that times out returned `[]`, which the
8862        // eval watch read as "window closed … it most likely ran — do not re-run it". A
8863        // listing that FAILS is not evidence; the eval must run to its own timeout instead.
8864        let Ok(mut s) = Arc::try_unwrap(state_with(PrivacyConfig::default())) else {
8865            unreachable!("fresh Arc has one owner")
8866        };
8867        s.eval_timeout = std::time::Duration::from_millis(2500);
8868        let state = Arc::new(s);
8869        let bridge = WedgedListingBridge(RecordingBridge::answering(state.pending_evals.clone()));
8870        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
8871        let r = call(
8872            &h,
8873            "eval_js",
8874            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8875        )
8876        .await;
8877        let text = result_text(&r);
8878        assert!(
8879            !text.contains("was closed"),
8880            "false 'window closed': {text}"
8881        );
8882        assert!(text.contains("timed out"), "{text}");
8883    }
8884
8885    /// `RecordingBridge`, but reporting a live `main` window (so only page loads are in play).
8886    struct MainWindowBridge(RecordingBridge);
8887
8888    impl WebviewBridge for MainWindowBridge {
8889        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
8890            self.0.eval_webview(label, script)
8891        }
8892        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
8893            self.0.get_window_states(label)
8894        }
8895        fn list_window_labels(&self) -> Vec<String> {
8896            vec!["main".to_string()]
8897        }
8898        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
8899            self.0.get_native_handle(label)
8900        }
8901        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
8902            self.0.manage_window(label, action)
8903        }
8904        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
8905            self.0.resize_window(label, w, h)
8906        }
8907        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
8908            self.0.move_window(label, x, y)
8909        }
8910        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
8911            self.0.set_window_title(label, title)
8912        }
8913    }
8914
8915    fn eval_state_with_timeout(ms: u64) -> Arc<VictauriState> {
8916        let Ok(mut s) = Arc::try_unwrap(state_with(PrivacyConfig::default())) else {
8917            unreachable!("fresh Arc has one owner")
8918        };
8919        s.eval_timeout = std::time::Duration::from_millis(ms);
8920        Arc::new(s)
8921    }
8922
8923    #[tokio::test]
8924    async fn eval_fails_fast_when_its_page_reloads() {
8925        let state = eval_state_with_timeout(20_000);
8926        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8927        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
8928        let reloader = state.clone();
8929        tokio::spawn(async move {
8930            tokio::time::sleep(std::time::Duration::from_millis(600)).await;
8931            inner.load_page("page-2"); // the page reloaded
8932            ready_signal(&reloader, "main", "page-2");
8933        });
8934        let started = std::time::Instant::now();
8935        let r = call(
8936            &h,
8937            "eval_js",
8938            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8939        )
8940        .await;
8941        let text = result_text(&r);
8942        assert!(text.contains("loaded a new page"), "{text}");
8943        assert!(
8944            started.elapsed() < std::time::Duration::from_secs(3),
8945            "must not wait out the 20s timeout (took {:?})",
8946            started.elapsed()
8947        );
8948        assert!(
8949            state.pending_evals.lock().await.is_empty(),
8950            "pending entry removed"
8951        );
8952    }
8953
8954    #[tokio::test]
8955    async fn a_late_ready_signal_from_the_same_page_is_not_a_reload() {
8956        // The bridge's ready signal is fire-and-forget at init and can land just after our
8957        // probe; it carries the nonce of the page the eval runs in and is not a reload.
8958        let state = eval_state_with_timeout(1_500);
8959        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8960        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner)));
8961        let late = state.clone();
8962        tokio::spawn(async move {
8963            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8964            ready_signal(&late, "main", "page-1");
8965        });
8966        let r = call(
8967            &h,
8968            "eval_js",
8969            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8970        )
8971        .await;
8972        let text = result_text(&r);
8973        assert!(!text.contains("loaded a new page"), "false reload: {text}");
8974        assert!(text.contains("timed out"), "{text}");
8975    }
8976
8977    #[tokio::test]
8978    async fn invoke_command_honors_timeout_ms_and_skips_aborted_timings() {
8979        let state = eval_state_with_timeout(20_000);
8980        let bridge = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone()));
8981        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
8982        let started = std::time::Instant::now();
8983        let r = call(
8984            &h,
8985            "invoke_command",
8986            json!({"command": "slow_thing", "webview_label": "main", "timeout_ms": 400}),
8987        )
8988        .await;
8989        let text = result_text(&r);
8990        assert!(
8991            started.elapsed() < std::time::Duration::from_secs(3),
8992            "timeout_ms ignored"
8993        );
8994        assert!(text.contains("timed out after 400ms"), "{text}");
8995        assert!(
8996            text.contains("timeout_ms"),
8997            "the error must point at timeout_ms: {text}"
8998        );
8999        assert!(
9000            state.command_timings.stats_for("slow_thing").is_none(),
9001            "a timed-out call is not a command duration"
9002        );
9003    }
9004
9005    #[tokio::test]
9006    async fn eval_reports_app_shutdown_instead_of_timing_out() {
9007        let state = slow_eval_state();
9008        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9009        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9010        let shutdown = state.clone();
9011        tokio::spawn(async move {
9012            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
9013            let _ = shutdown.shutdown_tx.send(true);
9014        });
9015        let r = call(
9016            &h,
9017            "eval_js",
9018            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
9019        )
9020        .await;
9021        let text = result_text(&r);
9022        assert_eq!(r.is_error, Some(true), "{text}");
9023        assert!(text.contains("shutting down"), "{text}");
9024        assert!(
9025            state.pending_evals.lock().await.is_empty(),
9026            "pending entry removed"
9027        );
9028    }
9029
9030    /// The bridge's ready signal for window `label`, from a page whose nonce is `nonce`.
9031    fn ready_signal(state: &VictauriState, label: &str, nonce: &str) {
9032        state.page_loads.record_load(label, Some(nonce));
9033    }
9034
9035    /// A UI thread that takes `0.1` to pick up every main-thread round trip (a busy app).
9036    struct SlowUiBridge(MainWindowBridge, std::time::Duration);
9037
9038    impl WebviewBridge for SlowUiBridge {
9039        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
9040            std::thread::sleep(self.1);
9041            self.0.eval_webview(label, script)
9042        }
9043        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9044            self.0.get_window_states(label)
9045        }
9046        fn list_window_labels(&self) -> Vec<String> {
9047            self.0.list_window_labels()
9048        }
9049        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
9050            self.0.get_native_handle(label)
9051        }
9052        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
9053            self.0.manage_window(label, action)
9054        }
9055        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
9056            self.0.resize_window(label, w, h)
9057        }
9058        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
9059            self.0.move_window(label, x, y)
9060        }
9061        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
9062            self.0.set_window_title(label, title)
9063        }
9064    }
9065
9066    /// #83: an eval's timeout covers the WHOLE call. The probe, injection and parse check are
9067    /// main-thread round trips; on a busy UI thread they took seconds each, and the wait for
9068    /// the result then started its own full timeout — a "3 s" eval took ~4.8 s here (three 600 ms
9069    /// round trips + 3 s; a 30 s one up to two minutes in 4DA). Now everything draws on one
9070    /// deadline: ~3 s.
9071    #[tokio::test]
9072    async fn an_eval_timeout_covers_the_whole_call_on_a_busy_ui_thread() {
9073        let state = eval_state_with_timeout(3_000);
9074        let inner = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone()));
9075        let bridge = SlowUiBridge(inner, std::time::Duration::from_millis(600));
9076        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9077        let (text, took) = hanging_eval(&h).await;
9078        assert!(text.contains("timed out"), "{text}");
9079        assert!(
9080            took < std::time::Duration::from_millis(4_000),
9081            "the wait restarted after the slow round trips: {took:?} for a 3 s eval"
9082        );
9083        assert!(
9084            state.pending_evals.lock().await.is_empty(),
9085            "pending entry removed"
9086        );
9087    }
9088
9089    /// A SHORT timeout on a slow (loaded) machine must not turn into a false failure: the probe
9090    /// and injection get the minimum send window, so a healthy page that answers the probe in
9091    /// 300 ms still receives a 100 ms call's code — instead of a "bridge not responding" that
9092    /// would send the agent hunting for a missing capability.
9093    #[tokio::test]
9094    async fn a_short_timeout_still_sends_when_the_probe_is_slower_than_it() {
9095        let state = eval_state_with_timeout(100);
9096        let recording = RecordingBridge::answering(state.pending_evals.clone());
9097        let bridge = SlowUiBridge(
9098            MainWindowBridge(recording.clone()),
9099            std::time::Duration::from_millis(300),
9100        );
9101        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9102        let r = call(
9103            &h,
9104            "invoke_command",
9105            json!({"command": "greet", "webview_label": "main"}),
9106        )
9107        .await;
9108        let text = result_text(&r);
9109        assert!(
9110            recording.invoked("greet"),
9111            "the code was never sent: {text}"
9112        );
9113        assert!(
9114            !text.contains("not responding"),
9115            "false dead-page diagnosis: {text}"
9116        );
9117    }
9118
9119    /// Pre-release audit: the result floor was set BEFORE the parse check, so on a busy UI
9120    /// thread the check could eat it — code that reached the page got less than its promised
9121    /// min(timeout, 1 s), and an answer arriving just after the check was reported as "timed out"
9122    /// (an agent retrying a side-effecting command ran it twice). Here every round trip takes
9123    /// 700 ms and the code answers 900 ms after delivery: injected at ~1.4 s, check done at
9124    /// ~2.1 s, answer at ~2.3 s — inside the floor counted from the check (2.6 s), past the one
9125    /// counted from injection (1.9 s).
9126    #[tokio::test]
9127    async fn code_that_reached_the_page_gets_its_full_result_window() {
9128        let state = eval_state_with_timeout(500);
9129        let recording = RecordingBridge::answering(state.pending_evals.clone());
9130        recording.answer_evals_after(
9131            r#"{"__victauri_ok":2,"__victauri_type":"value"}"#,
9132            std::time::Duration::from_millis(900),
9133        );
9134        let bridge = SlowUiBridge(
9135            MainWindowBridge(recording),
9136            std::time::Duration::from_millis(700),
9137        );
9138        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9139        let outcome = h
9140            .eval_outcome("1 + 1", Some("main"), std::time::Duration::from_millis(500))
9141            .await;
9142        assert!(
9143            outcome.is_ok(),
9144            "the answer came inside the result window but was reported as {:?}",
9145            outcome.err().map(|f| f.message)
9146        );
9147    }
9148
9149    /// Pre-release audit: a page whose bridge never signals ready (not loaded, or a window
9150    /// without the `victauri:default` capability) must still be PROBED within a short timeout,
9151    /// so the agent gets "bridge not responding — missing capability / page not loaded" — the
9152    /// ready wait used to eat the whole send window, and the call blamed a busy UI thread.
9153    #[tokio::test]
9154    async fn a_page_that_never_signals_ready_gets_the_dead_page_diagnosis() {
9155        let state = eval_state_with_timeout(1_000);
9156        state
9157            .bridge_ready
9158            .store(false, std::sync::atomic::Ordering::SeqCst);
9159        // No pending map: the liveness probe is never answered (no bridge in the page).
9160        let h = VictauriMcpHandler::new(
9161            state,
9162            Arc::new(MainWindowBridge(RecordingBridge::default())),
9163        );
9164        let started = std::time::Instant::now();
9165        let failure = h
9166            .eval_outcome("1 + 1", Some("main"), std::time::Duration::from_secs(1))
9167            .await
9168            .expect_err("nothing answers");
9169        assert_eq!(
9170            failure.kind,
9171            EvalFailureKind::NotSent,
9172            "{}",
9173            failure.message
9174        );
9175        assert!(
9176            failure.message.contains("bridge not responding"),
9177            "wrong diagnosis: {}",
9178            failure.message
9179        );
9180        assert!(
9181            started.elapsed() < std::time::Duration::from_millis(4_000),
9182            "{:?}",
9183            started.elapsed()
9184        );
9185    }
9186
9187    /// The call budget actually reaches the main-thread round trips: the probe and the
9188    /// injection run under it (capped near the call's send window), not the 10 s default.
9189    /// (`SlowUiBridge` alone cannot show this — it sleeps whether or not a budget exists.)
9190    #[tokio::test]
9191    async fn every_eval_round_trip_runs_under_the_calls_budget() {
9192        struct BudgetSpy(MainWindowBridge, Arc<StdMutex<Vec<std::time::Duration>>>);
9193        impl WebviewBridge for BudgetSpy {
9194            fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
9195                self.1
9196                    .lock()
9197                    .unwrap_or_else(std::sync::PoisonError::into_inner)
9198                    .push(crate::bridge::start_timeout());
9199                self.0.eval_webview(label, script)
9200            }
9201            fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9202                self.0.get_window_states(label)
9203            }
9204            fn list_window_labels(&self) -> Vec<String> {
9205                self.0.list_window_labels()
9206            }
9207            fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
9208                self.0.get_native_handle(label)
9209            }
9210            fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
9211                self.0.manage_window(label, action)
9212            }
9213            fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
9214                self.0.resize_window(label, w, h)
9215            }
9216            fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
9217                self.0.move_window(label, x, y)
9218            }
9219            fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
9220                self.0.set_window_title(label, title)
9221            }
9222        }
9223        let state = eval_state_with_timeout(300);
9224        let recording = RecordingBridge::answering(state.pending_evals.clone());
9225        recording.answer_evals_with(r#"{"__victauri_ok":2,"__victauri_type":"value"}"#);
9226        let seen = Arc::new(StdMutex::new(Vec::new()));
9227        let h = VictauriMcpHandler::new(
9228            state,
9229            Arc::new(BudgetSpy(MainWindowBridge(recording), Arc::clone(&seen))),
9230        );
9231        h.eval_outcome("1 + 1", Some("main"), std::time::Duration::from_millis(300))
9232            .await
9233            .expect("healthy page answers");
9234        let seen = seen
9235            .lock()
9236            .unwrap_or_else(std::sync::PoisonError::into_inner)
9237            .clone();
9238        assert!(
9239            seen.len() >= 3,
9240            "probe, injection and parse check: {seen:?}"
9241        );
9242        assert!(
9243            seen.iter().all(|b| *b <= MIN_SEND_WINDOW && !b.is_zero()),
9244            "a round trip ran outside the call's budget: {seen:?}"
9245        );
9246    }
9247
9248    /// A call that runs out of time before its code could be sent says so — and that the code
9249    /// never ran — instead of waiting for the UI thread.
9250    #[tokio::test]
9251    async fn an_eval_out_of_time_before_sending_reports_not_sent() {
9252        let state = eval_state_with_timeout(300);
9253        let inner = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone()));
9254        // Slower than the 2.5 s minimum send window, so the probe itself exhausts it.
9255        let bridge = SlowUiBridge(inner, std::time::Duration::from_millis(2_700));
9256        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9257        let failure = h
9258            .eval_outcome("1 + 1", Some("main"), std::time::Duration::from_millis(300))
9259            .await
9260            .expect_err("the budget ran out during the probe");
9261        assert_eq!(
9262            failure.kind,
9263            EvalFailureKind::NotSent,
9264            "{}",
9265            failure.message
9266        );
9267    }
9268
9269    /// Lens B (0.10.1 audit): the page commands (`victauri_eval_js` / `victauri_dom_snapshot`)
9270    /// run on the APP's runtime and take `pending_evals` with a fair-queued `.lock().await`. If
9271    /// such a waiter is queued when a Victauri map op releases the lock, tokio hands the lock to
9272    /// THAT task — which cannot run while the app pins its runtime. Every agent eval then hangs
9273    /// in `reserve_pending` (unbounded, before any deadline) until the app lets go.
9274    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
9275    async fn a_page_eval_queued_on_a_pinned_app_runtime_cannot_hang_agent_evals() {
9276        let state = eval_state_with_timeout(1_000);
9277        let recording = RecordingBridge::answering(state.pending_evals.clone());
9278        recording.answer_evals_with(r#"{"__victauri_ok":2,"__victauri_type":"value"}"#);
9279        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(recording)));
9280        // Tauri's runtime, reduced to one worker.
9281        let app_rt = tokio::runtime::Builder::new_multi_thread()
9282            .worker_threads(1)
9283            .enable_all()
9284            .build()
9285            .unwrap();
9286        // A Victauri map operation is in flight when the page asks for the lock.
9287        let guard = state.pending_evals.lock().await;
9288        let page_state = state.clone();
9289        app_rt.spawn(async move {
9290            let (tx, _rx) = tokio::sync::oneshot::channel();
9291            let _slot = crate::tools::reserve_page_eval(&page_state, "main", tx).await;
9292        });
9293        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9294        // The app blocks its runtime (a blocking call inside async code) for 6 s.
9295        app_rt.spawn(async { std::thread::sleep(std::time::Duration::from_secs(6)) });
9296        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9297        drop(guard);
9298
9299        let started = std::time::Instant::now();
9300        let out = tokio::time::timeout(
9301            std::time::Duration::from_secs(12),
9302            h.eval_outcome("1 + 1", Some("main"), std::time::Duration::from_secs(1)),
9303        )
9304        .await;
9305        let took = started.elapsed();
9306        app_rt.shutdown_background();
9307        // Documented bound: max(timeout, 2.5 s) + 2 s = 4.5 s.
9308        assert!(
9309            took < std::time::Duration::from_millis(4_500),
9310            "a 1 s agent eval hung {took:?} behind a page eval queued on the app's pinned \
9311             runtime (outcome: {:?})",
9312            out.map(|r| r.map_err(|f| f.message))
9313        );
9314    }
9315
9316    /// LENS-A-1: `wait_for`'s 12 s send slack assumed ONE main-thread start wait (10 s) plus the
9317    /// probe's 2 s answer — but the probe AND the injection each wait up to 10 s to start. On a
9318    /// UI thread that takes 6.5 s to pick up each round trip (inside every budget, so a real
9319    /// `on_main` would run them, not abandon them), the code reaches the page at ~13 s; the
9320    /// page's `waitFor` answers after its own 10 s (at ~23 s), past the eval's 22 s deadline —
9321    /// so a page answer (here: the condition was met at 9.9 s of page time) is reported as
9322    /// "eval timed out … an unresolved promise".
9323    #[tokio::test]
9324    async fn a_page_side_wait_for_answer_is_not_cut_off_by_slow_sending() {
9325        let state = eval_state_with_timeout(30_000);
9326        let recording = RecordingBridge::answering(state.pending_evals.clone());
9327        recording.answer_evals_after(
9328            r#"{"__victauri_ok":{"ok":true,"elapsed_ms":9900},"__victauri_type":"value"}"#,
9329            std::time::Duration::from_millis(9_900),
9330        );
9331        let bridge = SlowUiBridge(
9332            MainWindowBridge(recording),
9333            std::time::Duration::from_millis(6_500),
9334        );
9335        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9336        let r = call(
9337            &h,
9338            "wait_for",
9339            json!({"condition": "selector", "value": "#x", "timeout_ms": 10_000,
9340                   "webview_label": "main"}),
9341        )
9342        .await;
9343        let text = result_text(&r);
9344        assert!(
9345            !text.contains("timed out"),
9346            "the page's own wait_for answer was cut off by the send slack: {text}"
9347        );
9348        assert!(text.contains("\"ok\":true"), "{text}");
9349    }
9350
9351    /// LENS-A-2: a budget-honoring busy UI (a closure not picked up within the round trip's
9352    /// start budget is abandoned, as `on_main` does): 1.3 s pickup latency. The probe takes
9353    /// 1.3 s of the 2.5 s send window, the injection gets 1.2 s and is abandoned. The call is
9354    /// truthfully `NotSent`, but its message is the bare bridge error — not the "ran out of time
9355    /// before the code was sent … UI thread is busy … pass a longer timeout" diagnosis the same
9356    /// exhaustion gets one step earlier.
9357    #[tokio::test]
9358    async fn an_injection_abandoned_for_lack_of_budget_gets_the_busy_ui_diagnosis() {
9359        struct BudgetedSlowUi(MainWindowBridge, std::time::Duration);
9360        impl WebviewBridge for BudgetedSlowUi {
9361            fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
9362                let budget = crate::bridge::start_timeout();
9363                if self.1 > budget {
9364                    std::thread::sleep(budget);
9365                    return Err(
9366                        "eval_webview did not complete on the main thread: timed out \
9367                                before it started (it will not run)"
9368                            .to_string(),
9369                    );
9370                }
9371                std::thread::sleep(self.1);
9372                self.0.eval_webview(label, script)
9373            }
9374            fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9375                self.0.get_window_states(label)
9376            }
9377            fn list_window_labels(&self) -> Vec<String> {
9378                self.0.list_window_labels()
9379            }
9380            fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
9381                self.0.get_native_handle(label)
9382            }
9383            fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
9384                self.0.manage_window(label, action)
9385            }
9386            fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
9387                self.0.resize_window(label, w, h)
9388            }
9389            fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
9390                self.0.move_window(label, x, y)
9391            }
9392            fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
9393                self.0.set_window_title(label, title)
9394            }
9395        }
9396        // 1.3 s: the injection is the step that runs out; 2.7 s: the probe itself does.
9397        for latency_ms in [1_300, 2_700] {
9398            let state = eval_state_with_timeout(1_000);
9399            let recording = RecordingBridge::answering(state.pending_evals.clone());
9400            let bridge = BudgetedSlowUi(
9401                MainWindowBridge(recording),
9402                std::time::Duration::from_millis(latency_ms),
9403            );
9404            let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9405            let failure = h
9406                .eval_outcome(
9407                    "1 + 1",
9408                    Some("main"),
9409                    std::time::Duration::from_millis(1_000),
9410                )
9411                .await
9412                .expect_err("the delivery could not start in time");
9413            assert_eq!(
9414                failure.kind,
9415                EvalFailureKind::NotSent,
9416                "{}",
9417                failure.message
9418            );
9419            assert!(
9420                failure.message.contains("UI thread is busy")
9421                    && failure.message.contains("longer timeout"),
9422                "{latency_ms} ms: no busy-UI diagnosis / remedy: {}",
9423                failure.message
9424            );
9425        }
9426    }
9427
9428    async fn hanging_eval(h: &VictauriMcpHandler) -> (String, std::time::Duration) {
9429        let started = std::time::Instant::now();
9430        let r = call(
9431            h,
9432            "eval_js",
9433            json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
9434        )
9435        .await;
9436        (result_text(&r), started.elapsed())
9437    }
9438
9439    #[tokio::test]
9440    async fn a_reload_right_after_injection_is_detected() {
9441        // A reload that completes within a few ms of the injection (eval_js("location.reload()"),
9442        // navigate, a fast HMR) was hidden by a 250ms grace and waited out the full timeout.
9443        let state = eval_state_with_timeout(20_000);
9444        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
9445        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
9446        let reloader = state.clone();
9447        tokio::spawn(async move {
9448            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
9449            inner.load_page("page-2");
9450            ready_signal(&reloader, "main", "page-2");
9451        });
9452        let (text, took) = hanging_eval(&h).await;
9453        assert!(text.contains("loaded a new page"), "{text}");
9454        assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
9455    }
9456
9457    #[tokio::test]
9458    async fn a_same_page_ready_signal_arriving_late_is_not_a_reload() {
9459        // Under load the page's own ready signal can land seconds after the probe; it carries the
9460        // nonce the eval was armed in, so the eval keeps waiting for its real result.
9461        let state = eval_state_with_timeout(3_000);
9462        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
9463        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner)));
9464        let late = state.clone();
9465        tokio::spawn(async move {
9466            tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
9467            ready_signal(&late, "main", "page-1");
9468        });
9469        let (text, _) = hanging_eval(&h).await;
9470        assert!(!text.contains("loaded a new page"), "false reload: {text}");
9471        assert!(text.contains("timed out"), "{text}");
9472    }
9473
9474    #[tokio::test]
9475    async fn a_forged_ready_signal_does_not_abort_an_eval() {
9476        // Page script can call victauri_eval_callback('__victauri_bridge_ready__') itself. The
9477        // page did not change (the probe still reports the armed nonce), so it is not a reload.
9478        let state = eval_state_with_timeout(2_500);
9479        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
9480        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner)));
9481        let forger = state.clone();
9482        tokio::spawn(async move {
9483            tokio::time::sleep(std::time::Duration::from_millis(600)).await;
9484            ready_signal(&forger, "main", "forged");
9485        });
9486        let (text, _) = hanging_eval(&h).await;
9487        assert!(!text.contains("loaded a new page"), "forged reload: {text}");
9488        assert!(text.contains("timed out"), "{text}");
9489    }
9490
9491    /// R4-EVAL1: a ready signal whose confirming probe FAILS (a busy UI thread, a full slot
9492    /// map) is no evidence of a new page. The eval used to abort as "loaded a new page" — which
9493    /// page script can provoke with a forged signal while the UI is busy, and which invites a
9494    /// retry that runs side-effecting code twice. It must keep waiting (its own timeout bounds
9495    /// it).
9496    #[tokio::test]
9497    async fn an_unconfirmed_ready_signal_does_not_abort_an_eval() {
9498        let state = eval_state_with_timeout(4_000);
9499        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
9500        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
9501        let signal = state.clone();
9502        tokio::spawn(async move {
9503            tokio::time::sleep(std::time::Duration::from_millis(300)).await;
9504            inner.probe_silent.store(true, Ordering::SeqCst); // the UI stops answering
9505            ready_signal(&signal, "main", "forged");
9506        });
9507        let (text, _) = hanging_eval(&h).await;
9508        assert!(
9509            !text.contains("loaded a new page"),
9510            "aborted on no evidence: {text}"
9511        );
9512        assert!(text.contains("timed out"), "{text}");
9513    }
9514
9515    /// B-L5: the timeout message rules out a parse error only when the fast parse check was
9516    /// actually armed (the page reported a nonce) and delivered; otherwise it cannot know.
9517    #[tokio::test]
9518    async fn a_timeout_claims_no_parse_error_only_when_the_parse_check_ran() {
9519        let state = eval_state_with_timeout(300);
9520        // No nonce from the probe: the parse check is disabled.
9521        let h = VictauriMcpHandler::new(
9522            state.clone(),
9523            Arc::new(RecordingBridge::answering(state.pending_evals.clone())),
9524        );
9525        let (text, _) = hanging_eval(&h).await;
9526        assert!(text.contains("timed out"), "{text}");
9527        assert!(!text.contains("NOT a parse"), "unfounded claim: {text}");
9528        assert!(text.contains("could not be ruled out"), "{text}");
9529        // Armed and delivered: the claim holds.
9530        let h = VictauriMcpHandler::new(
9531            state.clone(),
9532            Arc::new(RecordingBridge::in_page(state.pending_evals.clone(), "p1")),
9533        );
9534        let (text, _) = hanging_eval(&h).await;
9535        assert!(text.contains("NOT a parse error"), "{text}");
9536        // Armed but the check could not be delivered.
9537        let h = VictauriMcpHandler::new(
9538            state.clone(),
9539            Arc::new(CheckNotDeliveredBridge(RecordingBridge::in_page(
9540                state.pending_evals.clone(),
9541                "p1",
9542            ))),
9543        );
9544        let (text, _) = hanging_eval(&h).await;
9545        assert!(!text.contains("NOT a parse"), "unfounded claim: {text}");
9546    }
9547
9548    /// Delivers everything but the eval's parse-check script.
9549    struct CheckNotDeliveredBridge(RecordingBridge);
9550
9551    impl WebviewBridge for CheckNotDeliveredBridge {
9552        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
9553            if script.contains("_evalCheck") {
9554                return Err("window busy".to_string());
9555            }
9556            self.0.eval_webview(label, script)
9557        }
9558        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9559            self.0.get_window_states(label)
9560        }
9561        fn list_window_labels(&self) -> Vec<String> {
9562            vec!["main".to_string()]
9563        }
9564        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
9565            self.0.get_native_handle(label)
9566        }
9567        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
9568            self.0.manage_window(label, action)
9569        }
9570        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
9571            self.0.resize_window(label, w, h)
9572        }
9573        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
9574            self.0.move_window(label, x, y)
9575        }
9576        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
9577            self.0.set_window_title(label, title)
9578        }
9579    }
9580
9581    /// A page that answers WITHOUT the armed nonce (the bridge that armed the eval is gone)
9582    /// is positive evidence of a new page.
9583    #[tokio::test]
9584    async fn a_page_without_the_armed_nonce_is_a_reload() {
9585        let state = eval_state_with_timeout(20_000);
9586        let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
9587        let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
9588        let reloader = state.clone();
9589        tokio::spawn(async move {
9590            tokio::time::sleep(std::time::Duration::from_millis(300)).await;
9591            *inner
9592                .page_nonce
9593                .lock()
9594                .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
9595            ready_signal(&reloader, "main", "whatever");
9596        });
9597        let (text, took) = hanging_eval(&h).await;
9598        assert!(text.contains("loaded a new page"), "{text}");
9599        assert!(took < std::time::Duration::from_secs(5), "took {took:?}");
9600    }
9601
9602    #[tokio::test]
9603    async fn an_eval_started_after_app_exit_fails_fast() {
9604        // `subscribe()` marks the current value seen: an eval started after the exit signal
9605        // waited its whole timeout for a change that had already happened.
9606        let state = slow_eval_state();
9607        state.shutdown_tx.send_replace(true);
9608        let bridge = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone()));
9609        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9610        let (text, took) =
9611            tokio::time::timeout(std::time::Duration::from_secs(10), hanging_eval(&h))
9612                .await
9613                .expect("an eval after app exit must not wait out its 20s timeout");
9614        assert!(text.contains("shutting down"), "{text}");
9615        assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
9616    }
9617
9618    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
9619    async fn a_dropped_eval_call_releases_its_pending_slot() {
9620        // REST runs the tool inside the axum future: a client that disconnects or times out
9621        // drops it mid-wait. The pending entry used to leak; 100 of them wedged every eval.
9622        let state = eval_state_with_timeout(20_000);
9623        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9624        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9625        for _ in 0..3 {
9626            let dropped = tokio::time::timeout(
9627                std::time::Duration::from_millis(300),
9628                h.execute_tool("eval_js", json!({"code": "await new Promise(() => {})"})),
9629            )
9630            .await;
9631            assert!(
9632                dropped.is_err(),
9633                "the call must still be in flight when dropped"
9634            );
9635        }
9636        // The recording drain reserves slots too (its page never answers here). It only reads
9637        // while a recording is active and its drain epoch is set, as `recording start` does.
9638        let generation = state
9639            .recorder
9640            .start_session("slot-release".to_string())
9641            .unwrap();
9642        state.drain_watermarks.reset(0.0, generation);
9643        let bridge: Arc<dyn WebviewBridge> = Arc::new(RecordingBridge::default());
9644        let dropped = tokio::time::timeout(
9645            std::time::Duration::from_millis(300),
9646            drain_window_into_recording(&state, &bridge, "main"),
9647        )
9648        .await;
9649        assert!(
9650            dropped.is_err(),
9651            "the drain must still be in flight when dropped"
9652        );
9653        // Removal on drop may be deferred to a task when the map is momentarily locked.
9654        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9655        assert!(
9656            state.pending_evals.lock().await.is_empty(),
9657            "a dropped call leaked its pending-eval slot"
9658        );
9659    }
9660
9661    /// Answers the liveness probe, but every other script fails to inject.
9662    struct InjectFailsBridge(RecordingBridge);
9663
9664    impl WebviewBridge for InjectFailsBridge {
9665        fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
9666            self.0.eval_webview(label, script)?;
9667            if script.contains("probe_ok") {
9668                Ok(())
9669            } else {
9670                Err("window not found: main".to_string())
9671            }
9672        }
9673        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9674            self.0.get_window_states(label)
9675        }
9676        fn list_window_labels(&self) -> Vec<String> {
9677            vec!["main".to_string()]
9678        }
9679        fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
9680            self.0.get_native_handle(label)
9681        }
9682        fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
9683            self.0.manage_window(label, action)
9684        }
9685        fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
9686            self.0.resize_window(label, w, h)
9687        }
9688        fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
9689            self.0.move_window(label, x, y)
9690        }
9691        fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
9692            self.0.set_window_title(label, title)
9693        }
9694    }
9695
9696    #[tokio::test]
9697    async fn invoke_command_records_no_timing_for_calls_that_never_ran() {
9698        // Only a call that reached the command is a command duration: a saturated pending map
9699        // (~0ms), a dead bridge (~2s probe) or a failed injection measure nothing about it.
9700        let state = eval_state_with_timeout(1_000);
9701        let fillers: Vec<_> = (0..MAX_PENDING_EVALS)
9702            .map(|_| tokio::sync::oneshot::channel::<String>())
9703            .collect();
9704        {
9705            let mut p = state.pending_evals.lock().await;
9706            for (i, (tx, _)) in fillers.into_iter().enumerate() {
9707                p.insert(format!("filler-{i}"), tx);
9708            }
9709        }
9710        let answering = RecordingBridge::answering(state.pending_evals.clone());
9711        let h = VictauriMcpHandler::new(state.clone(), Arc::new(answering.clone()));
9712        let r = call(&h, "invoke_command", json!({"command": "saturated"})).await;
9713        assert!(
9714            result_text(&r).contains("too many concurrent"),
9715            "{}",
9716            result_text(&r)
9717        );
9718        state.pending_evals.lock().await.clear();
9719
9720        let dead = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
9721        let r = call(&dead, "invoke_command", json!({"command": "dead_bridge"})).await;
9722        assert!(
9723            result_text(&r).contains("bridge not responding"),
9724            "{}",
9725            result_text(&r)
9726        );
9727
9728        let failing = VictauriMcpHandler::new(
9729            state.clone(),
9730            Arc::new(InjectFailsBridge(RecordingBridge::answering(
9731                state.pending_evals.clone(),
9732            ))),
9733        );
9734        let r = call(
9735            &failing,
9736            "invoke_command",
9737            json!({"command": "not_injected"}),
9738        )
9739        .await;
9740        assert!(
9741            result_text(&r).contains("injection failed"),
9742            "{}",
9743            result_text(&r)
9744        );
9745
9746        for cmd in ["saturated", "dead_bridge", "not_injected"] {
9747            assert!(
9748                state.command_timings.stats_for(cmd).is_none(),
9749                "'{cmd}' never ran but was recorded as a command timing"
9750            );
9751        }
9752
9753        // Code that did not parse never ran either.
9754        answering.answer_evals_with(r#"{"__victauri_not_run":"did not begin executing"}"#);
9755        let r = call(&h, "invoke_command", json!({"command": "never_parsed"})).await;
9756        assert!(
9757            result_text(&r).contains("parse error"),
9758            "{}",
9759            result_text(&r)
9760        );
9761        assert!(state.command_timings.stats_for("never_parsed").is_none());
9762
9763        // Positive control: a call that ran (and threw) IS a timing.
9764        answering.answer_evals_with(r#"{"__victauri_err":"boom"}"#);
9765        let r = call(&h, "invoke_command", json!({"command": "ran_and_threw"})).await;
9766        assert!(result_text(&r).contains("boom"), "{}", result_text(&r));
9767        assert!(state.command_timings.stats_for("ran_and_threw").is_some());
9768    }
9769
9770    #[tokio::test]
9771    async fn a_trusted_key_press_stops_when_its_element_cannot_be_focused() {
9772        // The focus result used to be ignored and the OS key sent regardless — into whatever
9773        // element (or app) held focus.
9774        let state = eval_state_with_timeout(2_000);
9775        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9776        bridge.answer_evals_with(r#"{"__victauri_ok":false,"__victauri_type":"value"}"#);
9777        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9778        let r = call(
9779            &h,
9780            "input",
9781            json!({"action": "press_key", "key": "Enter", "ref_id": "e9", "trusted": true}),
9782        )
9783        .await;
9784        let text = result_text(&r);
9785        assert_eq!(r.is_error, Some(true), "{text}");
9786        assert!(text.contains("not focusable"), "key sent anyway: {text}");
9787    }
9788
9789    /// G-12: `route add` forwarded any `delay_ms` (a u64) to the page, where a delayed request
9790    /// is held that long. It is capped like a `fault` delay, refused before reaching the page.
9791    #[tokio::test]
9792    async fn route_delay_is_capped() {
9793        let state = eval_state_with_timeout(2_000);
9794        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9795        bridge.answer_evals_with(&ok_envelope(&json!({"ok": true, "id": 1})));
9796        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9797        let added = |b: &RecordingBridge| {
9798            b.scripts
9799                .lock()
9800                .unwrap_or_else(std::sync::PoisonError::into_inner)
9801                .iter()
9802                .filter(|s| s.contains("addRoute("))
9803                .count()
9804        };
9805        let r = call(
9806            &h,
9807            "route",
9808            json!({"action": "add", "pattern": "/api", "behavior": "delay",
9809                   "delay_ms": MAX_FAULT_DELAY_MS + 1}),
9810        )
9811        .await;
9812        let text = result_text(&r);
9813        assert_eq!(r.is_error, Some(true), "{text}");
9814        assert!(text.contains("delay_ms"), "{text}");
9815        assert_eq!(added(&bridge), 0, "the rule must not reach the page");
9816        // At the cap it is accepted.
9817        let r = call(
9818            &h,
9819            "route",
9820            json!({"action": "add", "pattern": "/api", "behavior": "delay",
9821                   "delay_ms": MAX_FAULT_DELAY_MS}),
9822        )
9823        .await;
9824        assert_ne!(r.is_error, Some(true), "{}", result_text(&r));
9825        assert_eq!(added(&bridge), 1);
9826    }
9827
9828    /// R5-ANIM1: `animation scrub capture=true` reported `"captured": true` with no filmstrip
9829    /// when no frame could be captured (here: no native window handle), hiding the failure.
9830    #[tokio::test]
9831    async fn animation_scrub_never_claims_a_capture_it_did_not_make() {
9832        let state = eval_state_with_timeout(2_000);
9833        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9834        bridge.answer_evals_with(&ok_envelope(
9835            &json!({"prepared": true, "duration": 100, "anim_count": 1, "t": 0}),
9836        ));
9837        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9838        let r = call(
9839            &h,
9840            "animation",
9841            json!({"action": "scrub", "selector": "#toast", "points": 2, "capture": true}),
9842        )
9843        .await;
9844        let text = result_text(&r);
9845        assert_ne!(
9846            r.is_error,
9847            Some(true),
9848            "the geometry curve is still returned: {text}"
9849        );
9850        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
9851        assert_eq!(v["captured"], false, "{v}");
9852        assert!(v.get("filmstrip").is_none(), "{v}");
9853        assert!(
9854            v["capture_error"]
9855                .as_str()
9856                .is_some_and(|e| e.contains("no handle")),
9857            "the capture failure must be surfaced: {v}"
9858        );
9859        assert_eq!(v["curve"].as_array().map(Vec::len), Some(2), "{v}");
9860    }
9861
9862    /// The eval envelope for a page result `value`.
9863    /// R5-JS5: `limit: 0` means "return at most zero entries". The log JS used `.slice(-0)`
9864    /// (and the bridge treats a falsy limit as "all"), so it returned EVERY entry.
9865    #[tokio::test]
9866    async fn log_limit_zero_returns_no_entries() {
9867        let state = eval_state_with_timeout(2_000);
9868        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9869        bridge.answer_evals_with(&ok_envelope(&json!([{"a": 1}, {"a": 2}])));
9870        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9871        for action in [
9872            "console",
9873            "network",
9874            "ipc",
9875            "navigation",
9876            "dialogs",
9877            "events",
9878        ] {
9879            let r = call(&h, "logs", json!({"action": action, "limit": 0})).await;
9880            let text = result_text(&r);
9881            assert_ne!(r.is_error, Some(true), "{action}: {text}");
9882            let v: serde_json::Value = serde_json::from_str(&text)
9883                .unwrap_or_else(|e| panic!("{action}: not JSON ({e}): {text}"));
9884            assert_eq!(
9885                v,
9886                json!([]),
9887                "logs {action} limit=0 returned entries: {text}"
9888            );
9889        }
9890        let r = call(&h, "route", json!({"action": "matches", "limit": 0})).await;
9891        let text = result_text(&r);
9892        let v: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
9893        assert_eq!(
9894            v,
9895            json!([]),
9896            "route matches limit=0 returned entries: {text}"
9897        );
9898    }
9899
9900    fn ok_envelope(value: &serde_json::Value) -> String {
9901        json!({"__victauri_ok": value, "__victauri_type": "object"}).to_string()
9902    }
9903
9904    /// R4-IN1: trusted typing / key presses go out only when the page confirms focus landed
9905    /// on the element; an element that exists but did not take focus stops the input.
9906    #[tokio::test]
9907    async fn trusted_keys_are_sent_only_when_focus_landed_on_the_element() {
9908        let state = eval_state_with_timeout(2_000);
9909        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9910        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9911        let type_args =
9912            json!({"action": "type_text", "ref_id": "e3", "text": "hi", "trusted": true});
9913        let key_args =
9914            json!({"action": "press_key", "key": "Enter", "ref_id": "e3", "trusted": true});
9915
9916        bridge.answer_evals_with(&ok_envelope(&json!({"found": true, "focused": false})));
9917        for args in [&type_args, &key_args] {
9918            let r = call(&h, "input", args.clone()).await;
9919            let text = result_text(&r);
9920            assert_eq!(r.is_error, Some(true), "{args}: {text}");
9921            assert!(text.contains("focus did not land"), "{args}: {text}");
9922        }
9923        bridge.answer_evals_with(&ok_envelope(&json!({"found": false, "focused": false})));
9924        let r = call(&h, "input", type_args.clone()).await;
9925        assert!(
9926            result_text(&r).contains("ref not found"),
9927            "{}",
9928            result_text(&r)
9929        );
9930        assert!(
9931            bridge.natives().is_empty(),
9932            "keys sent: {:?}",
9933            bridge.natives()
9934        );
9935
9936        // Positive control: confirmed focus → the OS input goes out.
9937        bridge.answer_evals_with(&ok_envelope(&json!({"found": true, "focused": true})));
9938        for args in [&type_args, &key_args] {
9939            let r = call(&h, "input", args.clone()).await;
9940            assert_ne!(r.is_error, Some(true), "{args}: {}", result_text(&r));
9941        }
9942        assert_eq!(bridge.natives(), vec!["type hi", "key Enter"]);
9943    }
9944
9945    /// R4-IN2: a trusted click is sent only at a point the page vouched for, and never at an
9946    /// unusable one.
9947    #[tokio::test]
9948    async fn trusted_click_is_refused_unless_the_page_reports_a_clickable_point() {
9949        let state = eval_state_with_timeout(2_000);
9950        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9951        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9952        let args = json!({"action": "click", "ref_id": "e5", "trusted": true});
9953        for (answer, expect) in [
9954            (
9955                json!({"error": "element is covered at its center point by <div>"}),
9956                "covered",
9957            ),
9958            (json!({"x": -4.0, "y": 10.0}), "unusable click point"),
9959            (json!(null), "ref not found"),
9960        ] {
9961            bridge.answer_evals_with(&ok_envelope(&answer));
9962            let r = call(&h, "interact", args.clone()).await;
9963            let text = result_text(&r);
9964            assert_eq!(r.is_error, Some(true), "{answer}: {text}");
9965            assert!(text.contains(expect), "{answer}: {text}");
9966        }
9967        assert!(
9968            bridge.natives().is_empty(),
9969            "clicked: {:?}",
9970            bridge.natives()
9971        );
9972        bridge.answer_evals_with(&ok_envelope(&json!({"x": 150.0, "y": 226.0})));
9973        let r = call(&h, "interact", args).await;
9974        assert_ne!(r.is_error, Some(true), "{}", result_text(&r));
9975        assert_eq!(bridge.natives(), vec!["click 150,226"]);
9976    }
9977
9978    #[tokio::test]
9979    async fn an_unserializable_result_is_reported_as_code_that_ran() {
9980        // A circular object / BigInt result used to read "JavaScript error: …" although the code
9981        // ran — an agent then re-ran side-effecting code to "fix" it.
9982        let state = eval_state_with_timeout(2_000);
9983        let bridge = RecordingBridge::answering(state.pending_evals.clone());
9984        bridge.answer_evals_with(
9985            r#"{"__victauri_unserializable":"Do not know how to serialize a BigInt"}"#,
9986        );
9987        let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9988        let r = call(&h, "eval_js", json!({"code": "return 1n"})).await;
9989        let text = result_text(&r);
9990        assert_eq!(r.is_error, Some(true), "{text}");
9991        assert!(text.contains("the code ran"), "{text}");
9992        assert!(text.contains("BigInt"), "{text}");
9993        assert!(!text.contains("JavaScript error"), "{text}");
9994    }
9995
9996    #[tokio::test]
9997    async fn trace_stop_never_stops_a_recording_it_did_not_start() {
9998        let state = state_with(PrivacyConfig::default());
9999        let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
10000        let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await;
10001        let traced = state
10002            .recorder
10003            .active_session_id()
10004            .expect("trace started a recording");
10005        // The agent ends the trace's recording and starts its own.
10006        let _ = state.recorder.stop();
10007        state.recorder.start("mine".to_string()).unwrap();
10008        let _ = call(&h, "trace", json!({"action": "stop"})).await;
10009        assert_eq!(
10010            state.recorder.active_session_id().as_deref(),
10011            Some("mine"),
10012            "trace stop must not end a recording it did not start (it started {traced})"
10013        );
10014    }
10015
10016    #[tokio::test]
10017    async fn restarting_a_trace_does_not_orphan_its_recording() {
10018        let state = state_with(PrivacyConfig::default());
10019        let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
10020        let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await;
10021        let first = state.recorder.active_session_id().unwrap();
10022        let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await;
10023        let second = state.recorder.active_session_id().unwrap();
10024        assert_ne!(
10025            first, second,
10026            "the first trace's recording was superseded, not orphaned"
10027        );
10028        let _ = call(&h, "trace", json!({"action": "stop"})).await;
10029        assert!(
10030            !state.recorder.is_recording(),
10031            "stop ends the second trace's recording"
10032        );
10033    }
10034
10035    #[tokio::test]
10036    async fn import_refuses_to_discard_an_active_recording() {
10037        let state = state_with(PrivacyConfig::default());
10038        state.recorder.start("live".to_string()).unwrap();
10039        let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
10040        let session = RecordedSession::new(
10041            "other".to_string(),
10042            chrono::Utc::now(),
10043            Vec::new(),
10044            Vec::new(),
10045        );
10046        let r = call(
10047            &h,
10048            "recording",
10049            json!({"action": "import", "session_json": serde_json::to_string(&session).unwrap()}),
10050        )
10051        .await;
10052        assert_eq!(r.is_error, Some(true), "{}", result_text(&r));
10053        assert_eq!(
10054            state.recorder.export().unwrap().id,
10055            "live",
10056            "active recording kept"
10057        );
10058    }
10059
10060    // ── introspect.contract_record / contract_check (audit #30, A2) ───────────
10061
10062    #[tokio::test]
10063    async fn contract_record_never_invokes_a_blocklisted_command() {
10064        let bridge = RecordingBridge::default();
10065        let state = state_with(blocking(&["delete_account"]));
10066        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
10067
10068        let r = call(
10069            &h,
10070            "introspect",
10071            json!({"action": "contract_record", "command": "delete_account", "args": {"confirm": true}}),
10072        )
10073        .await;
10074
10075        assert!(
10076            !bridge.invoked("delete_account"),
10077            "SIDE-EFFECT LEAK: contract_record invoked a blocklisted command (audit #30)"
10078        );
10079        assert_eq!(r.is_error, Some(true));
10080        assert!(
10081            result_text(&r).contains("blocked by privacy configuration"),
10082            "got: {}",
10083            result_text(&r)
10084        );
10085    }
10086
10087    #[tokio::test]
10088    async fn contract_record_does_invoke_an_allowed_command() {
10089        let state = state_with(PrivacyConfig::default());
10090        let bridge = RecordingBridge::answering(state.pending_evals.clone());
10091        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
10092
10093        let _ = call(
10094            &h,
10095            "introspect",
10096            json!({"action": "contract_record", "command": "get_settings"}),
10097        )
10098        .await;
10099
10100        assert!(
10101            bridge.invoked("get_settings"),
10102            "positive control failed: contract_record did not invoke an allowed command"
10103        );
10104    }
10105
10106    /// R4-PANIC1: the stored sample was cut with `&s[..4096]`, which panics when byte 4096
10107    /// falls inside a multi-byte character — any non-ASCII response over 4 KiB.
10108    #[tokio::test]
10109    async fn contract_record_samples_a_large_non_ascii_response_without_panicking() {
10110        let state = eval_state_with_timeout(2_000);
10111        let bridge = RecordingBridge::answering(state.pending_evals.clone());
10112        // The result text is `"ééé…"`: the opening quote puts every `é` on an odd byte
10113        // offset, so byte 4096 is the second byte of a character.
10114        let payload = "é".repeat(3000);
10115        bridge.answer_evals_with(
10116            &json!({"__victauri_ok": payload, "__victauri_type": "string"}).to_string(),
10117        );
10118        let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
10119        let r = call(
10120            &h,
10121            "introspect",
10122            json!({"action": "contract_record", "command": "get_notes"}),
10123        )
10124        .await;
10125        let text = result_text(&r);
10126        assert_ne!(r.is_error, Some(true), "{text}");
10127        let baseline = state
10128            .contract_store
10129            .all()
10130            .into_iter()
10131            .find(|b| b.command == "get_notes")
10132            .expect("baseline recorded");
10133        assert!(
10134            baseline.sample.ends_with("...(truncated)"),
10135            "{}",
10136            baseline.sample
10137        );
10138        assert!(baseline.sample.len() <= 4096 + "...(truncated)".len());
10139    }
10140
10141    // ── pending-eval concurrency ceiling (audit: TOCTOU race) ────────────────
10142    #[tokio::test]
10143    async fn reserve_pending_is_a_hard_ceiling_under_concurrency() {
10144        // A check-then-insert (lock, read len(), unlock, …, lock, insert) races: many
10145        // concurrent callers all pass a STALE len() check before any inserts, blowing past
10146        // MAX_PENDING_EVALS. `reserve_pending` checks AND inserts under one lock, so the cap
10147        // is a true ceiling. Pre-fill to MAX-5, fire 50 concurrent reservations: EXACTLY 5
10148        // may succeed and the map must NEVER exceed the cap.
10149        let state = state_with(PrivacyConfig::default());
10150        {
10151            let mut p = state.pending_evals.lock().await;
10152            for i in 0..(MAX_PENDING_EVALS - 5) {
10153                let (tx, _rx) = tokio::sync::oneshot::channel();
10154                p.insert(format!("pre-{i}"), tx);
10155            }
10156        }
10157        let h = Arc::new(VictauriMcpHandler::new(
10158            state.clone(),
10159            Arc::new(RecordingBridge::default()),
10160        ));
10161        let mut tasks = Vec::new();
10162        for i in 0..50 {
10163            let h = h.clone();
10164            tasks.push(tokio::spawn(async move {
10165                let (tx, _rx) = tokio::sync::oneshot::channel();
10166                // keep rx and the slot alive until the reservation has been decided
10167                let slot = h.reserve_pending(&format!("c-{i}"), tx).await.ok();
10168                (slot, _rx)
10169            }));
10170        }
10171        let mut granted = 0;
10172        let mut keep = Vec::new();
10173        for t in tasks {
10174            let (slot, rx) = t.await.unwrap();
10175            if slot.is_some() {
10176                granted += 1;
10177            }
10178            keep.push((slot, rx)); // hold slots so reserved entries are not released
10179        }
10180        let len = state.pending_evals.lock().await.len();
10181        assert!(
10182            len <= MAX_PENDING_EVALS,
10183            "ceiling breached: {len} > {MAX_PENDING_EVALS}"
10184        );
10185        assert_eq!(
10186            granted, 5,
10187            "exactly the 5 free slots should have been reserved, got {granted}"
10188        );
10189        drop(keep);
10190    }
10191
10192    #[tokio::test]
10193    async fn contract_check_never_reinvokes_a_now_blocklisted_command() {
10194        // A baseline recorded before the command was blocked must not be re-invoked
10195        // once the operator adds it to the blocklist (audit #30).
10196        let bridge = RecordingBridge::default();
10197        let state = state_with(blocking(&["delete_account"]));
10198        state
10199            .contract_store
10200            .record(crate::introspection::ContractBaseline {
10201                command: "delete_account".to_string(),
10202                args: json!({}),
10203                shape: crate::introspection::JsonShape::from_value(&json!(true)),
10204                sample: "true".to_string(),
10205                recorded_at: chrono_now(),
10206            });
10207        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
10208
10209        let _ = call(&h, "introspect", json!({"action": "contract_check"})).await;
10210
10211        assert!(
10212            !bridge.invoked("delete_account"),
10213            "SIDE-EFFECT LEAK: contract_check re-invoked a now-blocklisted command (audit #30)"
10214        );
10215    }
10216
10217    // ── MCP resources honour the privacy gate (audit B1) ──────────────────────
10218
10219    #[test]
10220    fn resource_reads_are_gated_by_their_mirrored_capability() {
10221        // Resources bypass the tool dispatcher, so the read path must apply the same
10222        // gate. Disabling the capability a resource mirrors must block the resource.
10223        let cfg = PrivacyConfig {
10224            disabled_tools: HashSet::from([
10225                "logs.ipc".to_string(),
10226                "window.list".to_string(),
10227                "get_plugin_info".to_string(),
10228            ]),
10229            ..Default::default()
10230        };
10231        for uri in [
10232            RESOURCE_URI_IPC_LOG,
10233            RESOURCE_URI_WINDOWS,
10234            RESOURCE_URI_STATE,
10235        ] {
10236            let (_, cap) =
10237                resource_required_capability(uri).expect("resource maps to a capability");
10238            assert!(
10239                !cfg.is_tool_enabled(cap),
10240                "disabling capability {cap} must gate resource {uri} (audit B1)"
10241            );
10242            assert!(
10243                !resource_allowed(&cfg, uri),
10244                "disabling capability {cap} must gate resource {uri} (audit B1)"
10245            );
10246        }
10247        // Sanity: with nothing disabled, all three resources read.
10248        let full = PrivacyConfig::default();
10249        for uri in [
10250            RESOURCE_URI_IPC_LOG,
10251            RESOURCE_URI_WINDOWS,
10252            RESOURCE_URI_STATE,
10253        ] {
10254            assert!(full.is_tool_enabled(resource_required_capability(uri).unwrap().1));
10255            assert!(resource_allowed(&full, uri));
10256        }
10257    }
10258
10259    /// One JSON-RPC request to `/mcp` (legacy protocol, so the legacy subscribe method is
10260    /// routed); returns the response body text.
10261    async fn mcp_body(privacy: PrivacyConfig, method: &str, params: serde_json::Value) -> String {
10262        use http_body_util::BodyExt;
10263        use tower::ServiceExt;
10264        let app = build_app(state_with(privacy), Arc::new(RecordingBridge::default()));
10265        let body = json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params});
10266        let req = axum::http::Request::post("/mcp")
10267            .header("host", "127.0.0.1:7373")
10268            .header("content-type", "application/json")
10269            .header("accept", "application/json, text/event-stream")
10270            .header("mcp-protocol-version", "2025-06-18")
10271            .body(axum::body::Body::from(body.to_string()))
10272            .unwrap();
10273        let resp = tokio::time::timeout(std::time::Duration::from_secs(20), app.oneshot(req))
10274            .await
10275            .expect("an MCP request must be answered")
10276            .unwrap();
10277        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
10278        String::from_utf8_lossy(&bytes).into_owned()
10279    }
10280
10281    /// R4-NET3: a resource mirrors a tool action, and disabling the TOOL by its bare name
10282    /// (`disable_tools(["logs"])`) blocks every one of its actions — so it must block the
10283    /// resource too. The gate checked only the capability (`logs.ipc`), so the bare-name
10284    /// disable was ignored for resources (read and the legacy subscribe).
10285    #[tokio::test]
10286    async fn a_bare_tool_disable_also_blocks_its_resources() {
10287        for (disabled, uri) in [
10288            ("logs", RESOURCE_URI_IPC_LOG),
10289            ("window", RESOURCE_URI_WINDOWS),
10290            ("get_plugin_info", RESOURCE_URI_STATE),
10291        ] {
10292            let cfg = || PrivacyConfig {
10293                disabled_tools: HashSet::from([disabled.to_string()]),
10294                ..Default::default()
10295            };
10296            for method in ["resources/read", "resources/subscribe"] {
10297                let body = mcp_body(cfg(), method, json!({"uri": uri})).await;
10298                assert!(
10299                    body.contains("not permitted by the current privacy configuration"),
10300                    "disable_tools([{disabled:?}]) must block {method} {uri}: {body}"
10301                );
10302            }
10303        }
10304        // Positive control: nothing disabled, the resource reads.
10305        let body = mcp_body(
10306            PrivacyConfig::default(),
10307            "resources/read",
10308            json!({"uri": RESOURCE_URI_WINDOWS}),
10309        )
10310        .await;
10311        assert!(
10312            body.contains("\"contents\"") && !body.contains("not permitted"),
10313            "{body}"
10314        );
10315    }
10316
10317    // ── empty/whitespace auth token collapses to NO auth (audit B2) ───────────
10318
10319    #[tokio::test]
10320    async fn empty_auth_token_collapses_to_no_auth() {
10321        use http_body_util::BodyExt;
10322        use tower::ServiceExt;
10323
10324        for token in [Some(String::new()), Some("   ".to_string())] {
10325            let app = crate::mcp::server::build_app_full(
10326                state_with(PrivacyConfig::default()),
10327                Arc::new(RecordingBridge::default()),
10328                token.clone(),
10329                None,
10330            );
10331            let req = axum::extract::Request::builder()
10332                .uri("/info")
10333                .header("host", "127.0.0.1")
10334                .body(axum::body::Body::empty())
10335                .unwrap();
10336            let resp = app.oneshot(req).await.unwrap();
10337            assert_eq!(
10338                resp.status(),
10339                200,
10340                "/info must be reachable with empty token {token:?} (no auth layer)"
10341            );
10342            let bytes = resp.into_body().collect().await.unwrap().to_bytes();
10343            let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10344            assert_eq!(
10345                body["auth_required"],
10346                json!(false),
10347                "empty/whitespace token must report auth_required:false, not looks-protected-isnt (audit B2); token={token:?}"
10348            );
10349        }
10350    }
10351
10352    // ── app_info env allowlist drops secrets (audit #5/B3) ────────────────────
10353
10354    #[test]
10355    fn is_safe_env_key_drops_secrets_keeps_safe() {
10356        for secret in [
10357            "VICTAURI_AUTH_TOKEN",
10358            "TAURI_SIGNING_PRIVATE_KEY",
10359            "TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
10360            "CARGO_REGISTRY_TOKEN",
10361            "AWS_SECRET_ACCESS_KEY",
10362            "DATABASE_DSN",
10363            "GH_PAT",
10364        ] {
10365            assert!(
10366                !is_safe_env_key(secret),
10367                "{secret} is secret-shaped and must NOT be surfaced by app_info (audit #5)"
10368            );
10369        }
10370        for safe in [
10371            "HOME",
10372            "LANG",
10373            "TERM",
10374            "XDG_RUNTIME_DIR",
10375            "TAURI_ENV_PLATFORM",
10376        ] {
10377            assert!(
10378                is_safe_env_key(safe),
10379                "{safe} should be surfaced by app_info"
10380            );
10381        }
10382    }
10383}
10384
10385/// `screenshot` must refuse to "capture" a non-visible window.
10386///
10387/// Live-4DA dogfood (2026-06-16): requesting a hidden window (`label:"briefing"`)
10388/// returned a PNG that was actually the MAIN window's pixels — the OS capture path has
10389/// no live surface for an unmapped window, so it silently yields stale/foreign content.
10390/// The tool now checks visibility first and fails with an actionable message instead.
10391#[cfg(test)]
10392mod screenshot_visibility_tests {
10393    use super::*;
10394    use crate::bridge::WebviewBridge;
10395    use crate::privacy::PrivacyConfig;
10396    use std::collections::HashMap;
10397    use std::sync::Mutex as StdMutex;
10398    use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
10399
10400    fn window(label: &str, visible: bool) -> WindowState {
10401        WindowState::new(label.to_string())
10402            .with_title(label.to_string())
10403            .with_url("http://localhost/".to_string())
10404            .with_visible(visible)
10405            .with_focused(false)
10406            .with_maximized(false)
10407            .with_minimized(false)
10408            .with_fullscreen(false)
10409            .with_position(0, 0)
10410            .with_size(800, 600)
10411    }
10412
10413    /// A bridge with a configurable window set that RECORDS the label `get_native_handle`
10414    /// is asked for, then errs. Recording the label lets a test assert WHICH window the
10415    /// screenshot tool resolved to (the audit-P2 case: omitted label must resolve to a
10416    /// VISIBLE window, never hidden "main"); the error lets a test prove the visibility gate
10417    /// fired *before* the OS-handle path was reached.
10418    struct ConfigBridge {
10419        windows: Vec<WindowState>,
10420        handle_label: Arc<StdMutex<Option<Option<String>>>>,
10421    }
10422
10423    impl ConfigBridge {
10424        fn new(windows: Vec<WindowState>) -> Self {
10425            Self {
10426                windows,
10427                handle_label: Arc::new(StdMutex::new(None)),
10428            }
10429        }
10430        /// The label `get_native_handle` was called with, if it was reached.
10431        /// `Some(Some(l))` = called with label `l`; `Some(None)` = called with the default;
10432        /// `None` = never reached (gate short-circuited).
10433        fn requested_handle(&self) -> Option<Option<String>> {
10434            self.handle_label
10435                .lock()
10436                .unwrap_or_else(std::sync::PoisonError::into_inner)
10437                .clone()
10438        }
10439    }
10440
10441    impl WebviewBridge for ConfigBridge {
10442        fn eval_webview(&self, _l: Option<&str>, _s: &str) -> Result<(), String> {
10443            Err("no eval".to_string())
10444        }
10445        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
10446            match label {
10447                Some(l) => self
10448                    .windows
10449                    .iter()
10450                    .filter(|w| w.label == l)
10451                    .cloned()
10452                    .collect(),
10453                None => self.windows.clone(),
10454            }
10455        }
10456        fn list_window_labels(&self) -> Vec<String> {
10457            self.windows.iter().map(|w| w.label.clone()).collect()
10458        }
10459        fn get_native_handle(&self, l: Option<&str>) -> Result<isize, String> {
10460            *self
10461                .handle_label
10462                .lock()
10463                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(l.map(str::to_string));
10464            Err("native handle path reached".to_string())
10465        }
10466        fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
10467            Ok(String::new())
10468        }
10469        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
10470            Ok(())
10471        }
10472        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
10473            Ok(())
10474        }
10475        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
10476            Ok(())
10477        }
10478    }
10479
10480    fn handler_with(bridge: Arc<ConfigBridge>) -> VictauriMcpHandler {
10481        let state = Arc::new(VictauriState {
10482            event_log: EventLog::new(100),
10483            registry: CommandRegistry::new(),
10484            port: std::sync::atomic::AtomicU16::new(0),
10485            pending_evals: Arc::new(Mutex::new(HashMap::new())),
10486            recorder: EventRecorder::new(100),
10487            privacy: PrivacyConfig::default(),
10488            eval_timeout: std::time::Duration::from_millis(100),
10489            shutdown_tx: tokio::sync::watch::channel(false).0,
10490            started_at: std::time::Instant::now(),
10491            tool_invocations: std::sync::atomic::AtomicU64::new(0),
10492            allow_file_navigation: false,
10493            command_timings: crate::introspection::CommandTimings::new(),
10494            fault_registry: crate::introspection::FaultRegistry::new(),
10495            contract_store: crate::introspection::ContractStore::new(),
10496            startup_timeline: crate::introspection::StartupTimeline::new(),
10497            event_bus: crate::introspection::EventBusMonitor::default(),
10498            task_tracker: crate::introspection::TaskTracker::new(),
10499            bridge_ready: std::sync::atomic::AtomicBool::new(true),
10500            bridge_notify: tokio::sync::Notify::new(),
10501            db_search_paths: Vec::new(),
10502            screencast: Arc::new(crate::screencast::Screencast::default()),
10503            probes: crate::introspection::AppStateProbes::default(),
10504            drain_watermarks: crate::introspection::DrainWatermarks::default(),
10505            page_loads: crate::introspection::PageLoads::default(),
10506            server_runtime: std::sync::OnceLock::new(),
10507            backend_logs: std::sync::Arc::new(crate::backend_logs::LogBuffer::default()),
10508        });
10509        VictauriMcpHandler::new(state, bridge)
10510    }
10511
10512    fn error_text(r: &CallToolResult) -> String {
10513        r.content
10514            .iter()
10515            .filter_map(|c| match c {
10516                ContentBlock::Text(t) => Some(t.text.clone()),
10517                _ => None,
10518            })
10519            .collect::<Vec<_>>()
10520            .join("\n")
10521    }
10522
10523    #[tokio::test]
10524    async fn hidden_window_screenshot_errors_clearly() {
10525        let bridge = Arc::new(ConfigBridge::new(vec![
10526            window("main", true),
10527            window("briefing", false),
10528        ]));
10529        let h = handler_with(bridge.clone());
10530        let r = h
10531            .screenshot(Parameters(ScreenshotParams {
10532                window_label: Some("briefing".to_string()),
10533            }))
10534            .await;
10535        assert_eq!(r.is_error, Some(true), "hidden window must error");
10536        let text = error_text(&r);
10537        assert!(
10538            text.contains("not visible"),
10539            "error must explain the window is not visible, got: {text}"
10540        );
10541        assert!(
10542            bridge.requested_handle().is_none(),
10543            "must short-circuit BEFORE the OS-handle/capture path"
10544        );
10545    }
10546
10547    #[tokio::test]
10548    async fn visible_window_screenshot_proceeds_to_capture() {
10549        let bridge = Arc::new(ConfigBridge::new(vec![
10550            window("main", true),
10551            window("briefing", false),
10552        ]));
10553        let h = handler_with(bridge.clone());
10554        let r = h
10555            .screenshot(Parameters(ScreenshotParams {
10556                window_label: Some("main".to_string()),
10557            }))
10558            .await;
10559        // The gate must let a visible window THROUGH to the OS-handle path (which this mock
10560        // fails) — proving the gate only blocks hidden windows.
10561        let text = error_text(&r);
10562        assert!(
10563            text.contains("native handle path reached")
10564                || text.contains("cannot get window handle"),
10565            "a visible window must reach the capture path, got: {text}"
10566        );
10567        assert_eq!(
10568            bridge.requested_handle(),
10569            Some(Some("main".to_string())),
10570            "must capture the explicitly requested visible window"
10571        );
10572    }
10573
10574    // GPT audit (P2): `screenshot {}` with NO label previously resolved through
10575    // find_window(None), which prefers "main" UNCONDITIONALLY — so an app that hides main but
10576    // keeps a secondary window visible captured hidden main (the wrong-pixels class the PR
10577    // exists to prevent). The tool must now resolve its own VISIBLE target.
10578    #[tokio::test]
10579    async fn omitted_label_skips_hidden_main_for_visible_secondary() {
10580        let bridge = Arc::new(ConfigBridge::new(vec![
10581            window("main", false),     // main hidden
10582            window("secondary", true), // a different window is visible
10583        ]));
10584        let h = handler_with(bridge.clone());
10585        let r = h
10586            .screenshot(Parameters(ScreenshotParams { window_label: None }))
10587            .await;
10588        let text = error_text(&r);
10589        assert!(
10590            !text.contains("not visible") && !text.contains("no visible window"),
10591            "a visible secondary window exists — must NOT error, got: {text}"
10592        );
10593        assert_eq!(
10594            bridge.requested_handle(),
10595            Some(Some("secondary".to_string())),
10596            "omitted label must resolve to the VISIBLE secondary, never hidden main"
10597        );
10598    }
10599
10600    // Omitted label with a visible main present must still prefer "main".
10601    #[tokio::test]
10602    async fn omitted_label_prefers_visible_main() {
10603        let bridge = Arc::new(ConfigBridge::new(vec![
10604            window("main", true),
10605            window("secondary", true),
10606        ]));
10607        let h = handler_with(bridge.clone());
10608        let _ = h
10609            .screenshot(Parameters(ScreenshotParams { window_label: None }))
10610            .await;
10611        assert_eq!(
10612            bridge.requested_handle(),
10613            Some(Some("main".to_string())),
10614            "with a visible main present, omitted label must resolve to main"
10615        );
10616    }
10617
10618    // Every window hidden + omitted label: error clearly, never capture a hidden window.
10619    #[tokio::test]
10620    async fn all_hidden_omitted_label_errors() {
10621        let bridge = Arc::new(ConfigBridge::new(vec![
10622            window("main", false),
10623            window("briefing", false),
10624        ]));
10625        let h = handler_with(bridge.clone());
10626        let r = h
10627            .screenshot(Parameters(ScreenshotParams { window_label: None }))
10628            .await;
10629        assert_eq!(r.is_error, Some(true), "all-hidden must error");
10630        assert!(
10631            error_text(&r).contains("no visible window"),
10632            "error must say there is no visible window, got: {}",
10633            error_text(&r)
10634        );
10635        assert!(
10636            bridge.requested_handle().is_none(),
10637            "must NOT reach the OS-handle path when every window is hidden"
10638        );
10639    }
10640
10641    // An unknown explicit label is passed THROUGH to get_native_handle (which produces the
10642    // canonical "window not found"), not rejected as "not visible".
10643    #[tokio::test]
10644    async fn unknown_label_falls_through_to_handle_resolution() {
10645        let bridge = Arc::new(ConfigBridge::new(vec![window("main", true)]));
10646        let h = handler_with(bridge.clone());
10647        let r = h
10648            .screenshot(Parameters(ScreenshotParams {
10649                window_label: Some("ghost".to_string()),
10650            }))
10651            .await;
10652        assert!(
10653            !error_text(&r).contains("not visible"),
10654            "unknown label must not be reported as 'not visible'"
10655        );
10656        assert_eq!(
10657            bridge.requested_handle(),
10658            Some(Some("ghost".to_string())),
10659            "unknown label must be forwarded verbatim to get_native_handle"
10660        );
10661    }
10662}