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