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 let canonical = std::fs::canonicalize(candidate)
4034 .map_err(|e| format!("cannot resolve database path: {e}"))?;
4035 return Ok(canonical);
4036 }
4037 return Err(format!(
4038 "absolute path '{requested}' is not within an allowed directory; \
4039 register its parent via VictauriBuilder::db_search_paths"
4040 ));
4041 }
4042
4043 Self::lexical_safe(candidate)?;
4044 for root in roots {
4045 let resolved = root.join(candidate);
4046 if resolved.exists() {
4047 Self::safe_within(root, &resolved)?;
4048 let canonical = std::fs::canonicalize(&resolved)
4053 .map_err(|e| format!("cannot resolve database path: {e}"))?;
4054 return Ok(canonical);
4055 }
4056 }
4057
4058 let roots = roots
4059 .iter()
4060 .map(|root| root.display().to_string())
4061 .collect::<Vec<_>>()
4062 .join(", ");
4063 Err(format!(
4064 "database not found: {requested} (searched: {roots})"
4065 ))
4066 }
4067
4068 #[cfg(feature = "sqlite")]
4069 fn quote_sqlite_identifier(identifier: &str) -> String {
4070 format!("\"{}\"", identifier.replace('"', "\"\""))
4071 }
4072
4073 fn list_dir_recursive(
4074 dir: &std::path::Path,
4075 base: &std::path::Path,
4076 depth: u32,
4077 max_depth: u32,
4078 pattern: Option<&str>,
4079 entries: &mut Vec<serde_json::Value>,
4080 ) {
4081 if entries.len() >= MAX_DIR_ENTRIES {
4082 return;
4083 }
4084 let Ok(read_dir) = std::fs::read_dir(dir) else {
4085 return;
4086 };
4087 for entry in read_dir.flatten() {
4088 if entries.len() >= MAX_DIR_ENTRIES {
4089 return;
4090 }
4091 let path = entry.path();
4092 if path.is_symlink() {
4093 continue;
4094 }
4095 if Self::safe_within(base, &path).is_err() {
4099 continue;
4100 }
4101 let name = entry.file_name().to_string_lossy().into_owned();
4102 let relative = path
4103 .strip_prefix(base)
4104 .unwrap_or(&path)
4105 .to_string_lossy()
4106 .into_owned();
4107
4108 if let Some(pat) = pattern
4109 && !Self::matches_glob(&name, pat)
4110 && !path.is_dir()
4111 {
4112 continue;
4113 }
4114
4115 let is_dir = path.is_dir();
4116 let meta = std::fs::metadata(&path).ok();
4117
4118 entries.push(serde_json::json!({
4119 "name": name,
4120 "path": relative,
4121 "is_dir": is_dir,
4122 "size": meta.as_ref().map(std::fs::Metadata::len),
4123 "modified": meta.as_ref()
4124 .and_then(|m| m.modified().ok())
4125 .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
4126 .unwrap_or_default().as_secs()),
4127 }));
4128
4129 if is_dir && depth < max_depth {
4130 Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
4131 }
4132 }
4133 }
4134
4135 fn matches_glob(name: &str, pattern: &str) -> bool {
4136 if pattern == "*" {
4137 return true;
4138 }
4139 if let Some(suffix) = pattern.strip_prefix("*.") {
4140 return name.ends_with(&format!(".{suffix}"));
4141 }
4142 if let Some(prefix) = pattern.strip_suffix("*") {
4143 return name.starts_with(prefix);
4144 }
4145 name == pattern
4146 }
4147
4148 async fn window_introspectability(&self) -> CallToolResult {
4154 let labels = self.bridge.list_window_labels();
4155 let states = self.bridge.get_window_states(None);
4156 let mut report = Vec::with_capacity(labels.len());
4157 let mut blind = 0usize;
4158 for label in &labels {
4159 let visible = states.iter().find(|s| &s.label == label).map(|s| s.visible);
4160 let introspectable = self.probe_bridge(Some(label)).await.is_ok();
4161 if !introspectable {
4162 blind += 1;
4163 }
4164 let note = if introspectable {
4165 "ok — Victauri JS bridge is responding".to_string()
4166 } else if visible == Some(true) {
4167 format!(
4168 "NOT introspectable although the window is visible — almost certainly missing \
4169 the Victauri capability. Add \"victauri:default\" to the capability file \
4170 (src-tauri/capabilities/*.json) whose \"windows\" list includes \"{label}\", \
4171 then rebuild. Capabilities are baked at compile time, so a rebuild is required."
4172 )
4173 } else {
4174 "NOT introspectable (window is hidden and/or has no bridge) — show the window to \
4175 confirm, and ensure its capability includes \"victauri:default\", then rebuild."
4176 .to_string()
4177 };
4178 report.push(serde_json::json!({
4179 "label": label,
4180 "visible": visible,
4181 "introspectable": introspectable,
4182 "note": note,
4183 }));
4184 }
4185 let hint = if blind > 0 {
4186 "Windows with introspectable:false have no working Victauri JS bridge — eval_js, \
4187 dom_snapshot, animation, find_elements, etc. cannot see them. The usual cause is a \
4188 missing \"victauri:default\" capability for that window: Tauri's per-window permission \
4189 ACL silently blocks the bridge's callback IPC. This capability is required per window, \
4190 not just for the main window. (Note: probing a blind window takes ~2s each.)"
4191 } else {
4192 "All windows are introspectable."
4193 };
4194 json_result(&serde_json::json!({
4195 "windows": report,
4196 "introspectable_count": labels.len().saturating_sub(blind),
4197 "blind_count": blind,
4198 "hint": hint,
4199 }))
4200 }
4201
4202 async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
4203 match self.eval_with_return(code, webview_label).await {
4204 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
4205 Err(e) => tool_error(e),
4206 }
4207 }
4208
4209 async fn eval_with_return(
4210 &self,
4211 code: &str,
4212 webview_label: Option<&str>,
4213 ) -> Result<String, String> {
4214 self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
4215 .await
4216 }
4217
4218 async fn reserve_pending(
4225 &self,
4226 id: &str,
4227 tx: tokio::sync::oneshot::Sender<String>,
4228 ) -> Result<(), String> {
4229 let mut pending = self.state.pending_evals.lock().await;
4230 if pending.len() >= MAX_PENDING_EVALS {
4231 return Err(format!(
4232 "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
4233 ));
4234 }
4235 pending.insert(id.to_string(), tx);
4236 Ok(())
4237 }
4238
4239 async fn probe_bridge(&self, webview_label: Option<&str>) -> Result<(), String> {
4240 let id = uuid::Uuid::new_v4().to_string();
4241 let (tx, rx) = tokio::sync::oneshot::channel();
4242 self.reserve_pending(&id, tx).await?;
4243 let id_js = js_string(&id);
4244 let probe = format!(
4245 r#"(async()=>{{await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',{{id:{id_js},result:'"probe_ok"'}});}})();"#
4246 );
4247 if let Err(e) = self.bridge.eval_webview(webview_label, &probe) {
4248 self.state.pending_evals.lock().await.remove(&id);
4249 return Err(format!("eval injection failed: {e}"));
4250 }
4251 if let Ok(Ok(_)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await {
4252 Ok(())
4253 } else {
4254 self.state.pending_evals.lock().await.remove(&id);
4255 let label = webview_label.unwrap_or("default");
4256 Err(format!(
4257 "bridge not responding on window '{label}' — the window may be hidden, \
4258 missing the victauri capability, or the JS bridge is not loaded (e.g. the page \
4259 failed to load: a dev-server connection-refused or blank error page has no JS \
4260 bridge — check the window with the `screenshot` tool, which works regardless)"
4261 ))
4262 }
4263 }
4264
4265 async fn eval_with_return_timeout(
4266 &self,
4267 code: &str,
4268 webview_label: Option<&str>,
4269 timeout: std::time::Duration,
4270 ) -> Result<String, String> {
4271 if !self
4283 .state
4284 .bridge_ready
4285 .load(std::sync::atomic::Ordering::Acquire)
4286 {
4287 let notified = self.state.bridge_notify.notified();
4288 if !self
4289 .state
4290 .bridge_ready
4291 .load(std::sync::atomic::Ordering::Acquire)
4292 {
4293 let _ = tokio::time::timeout(std::time::Duration::from_secs(5), notified).await;
4294 }
4295 }
4296
4297 let label_key =
4300 webview_label.map_or_else(|| "\u{1}__default__".to_string(), str::to_string);
4301
4302 let prev_timed_out = self.timed_out_labels.lock().await.remove(&label_key);
4315 if let Err(e) = self.probe_bridge(webview_label).await {
4316 return Err(if prev_timed_out {
4317 format!(
4318 "{e} (a previous eval on this window also timed out — the webview \
4319 likely reloaded or the app stopped responding)"
4320 )
4321 } else {
4322 e
4323 });
4324 }
4325
4326 let id = uuid::Uuid::new_v4().to_string();
4327 let (tx, rx) = tokio::sync::oneshot::channel();
4328 self.reserve_pending(&id, tx).await?;
4329
4330 let code = if should_prepend_return(code) {
4337 format!("return {}", code.trim())
4338 } else {
4339 code.trim().to_string()
4340 };
4341
4342 let id_js = js_string(&id);
4343
4344 let watchdog = format!(
4357 r"
4358 (function () {{
4359 window.__VIC_EVAL__ = window.__VIC_EVAL__ || {{}};
4360 var s = (window.__VIC_EVAL__[{id_js}] =
4361 window.__VIC_EVAL__[{id_js}] || {{ started: false, done: false }});
4362 setTimeout(function () {{
4363 if (s.started || s.done) return;
4364 s.done = true;
4365 try {{
4366 window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4367 id: {id_js},
4368 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)' }})
4369 }});
4370 }} catch (e) {{}}
4371 delete window.__VIC_EVAL__[{id_js}];
4372 }}, {PARSE_WATCHDOG_MS});
4373 }})();
4374 "
4375 );
4376
4377 let inject = format!(
4378 r"
4379 (async () => {{
4380 var __s = (window.__VIC_EVAL__ && window.__VIC_EVAL__[{id_js}]) || null;
4381 if (__s) __s.started = true;
4382 try {{
4383 const __result = await (async () => {{ {code} }})();
4384 if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }}
4385 const __type = __result === undefined ? 'undefined'
4386 : __result === null ? 'null' : 'value';
4387 const __val = __type === 'undefined' ? null
4388 : __type === 'null' ? null : __result;
4389 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4390 id: {id_js},
4391 result: JSON.stringify({{ __victauri_ok: __val, __victauri_type: __type }})
4392 }});
4393 }} catch (e) {{
4394 if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }}
4395 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4396 id: {id_js},
4397 result: JSON.stringify({{ __victauri_err: String(e && e.message || e) }})
4398 }});
4399 }}
4400 }})();
4401 "
4402 );
4403
4404 if let Err(e) = self.bridge.eval_webview(webview_label, &watchdog) {
4408 self.state.pending_evals.lock().await.remove(&id);
4409 return Err(format!("eval injection failed: {e}"));
4410 }
4411 if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
4412 self.state.pending_evals.lock().await.remove(&id);
4413 return Err(format!("eval injection failed: {e}"));
4414 }
4415
4416 match tokio::time::timeout(timeout, rx).await {
4417 Ok(Ok(raw)) => {
4418 self.check_bridge_version_once();
4419 if raw.len() > MAX_EVAL_RESULT_LEN {
4420 return Err(format!(
4421 "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
4422 raw.len()
4423 ));
4424 }
4425 unwrap_eval_envelope(raw)
4426 }
4427 Ok(Err(_)) => Err("eval callback channel closed".to_string()),
4428 Err(_) => {
4429 self.state.pending_evals.lock().await.remove(&id);
4430 self.timed_out_labels.lock().await.insert(label_key.clone());
4434 Err(format!(
4435 "eval timed out after {}s — the code began executing but never resolved. \
4436 (A syntax/parse error would have failed fast via the parse watchdog, so \
4437 this is NOT a parse error.) Common causes: an unresolved promise, an \
4438 infinite loop, an `await` on something that never settles, or the webview \
4439 reloaded / the app stopped responding mid-eval. If the app may have \
4440 navigated or crashed, retry (the next call fails fast if the bridge is \
4441 gone).",
4442 timeout.as_secs()
4443 ))
4444 }
4445 }
4446 }
4447
4448 #[cfg(feature = "sqlite")]
4449 async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
4450 let mut roots: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
4452 for d in [
4453 self.bridge.app_data_dir(),
4454 self.bridge.app_local_data_dir(),
4455 self.bridge.app_config_dir(),
4456 ]
4457 .into_iter()
4458 .flatten()
4459 {
4460 roots.push(d);
4461 }
4462
4463 let path = if let Some(p) = db_path {
4464 Self::resolve_existing_db_path(&roots, p)?
4465 } else {
4466 let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
4470 roots.clone()
4471 } else {
4472 self.state.db_search_paths.clone()
4473 };
4474 crate::database::select_app_database(&select_dirs)?
4475 };
4476 let path_str = path
4477 .to_str()
4478 .ok_or_else(|| "invalid path encoding".to_string())?
4479 .to_string();
4480
4481 tokio::task::spawn_blocking(move || {
4482 let conn = rusqlite::Connection::open_with_flags(
4483 &path_str,
4484 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
4485 )
4486 .map_err(|e| format!("cannot open database: {e}"))?;
4487 conn.set_limit(
4488 rusqlite::limits::Limit::SQLITE_LIMIT_LENGTH,
4489 MAX_DB_HEALTH_CELL_BYTES,
4490 );
4491 let started = std::time::Instant::now();
4492 let timed_out = Arc::new(AtomicBool::new(false));
4493 let timeout_marker = Arc::clone(&timed_out);
4494 conn.progress_handler(
4495 DB_HEALTH_PROGRESS_OPS,
4496 Some(move || {
4497 let expired = started.elapsed() >= DB_HEALTH_TIMEOUT;
4498 if expired {
4499 timeout_marker.store(true, Ordering::Relaxed);
4500 }
4501 expired
4502 }),
4503 );
4504 let _interrupt = crate::database::InterruptGuard::arm(&conn, DB_HEALTH_TIMEOUT);
4507
4508 let journal_mode: String = conn
4509 .pragma_query_value(None, "journal_mode", |r| r.get(0))
4510 .unwrap_or_else(|_| "unknown".to_string());
4511
4512 let page_count: i64 = conn
4513 .pragma_query_value(None, "page_count", |r| r.get(0))
4514 .unwrap_or(0);
4515
4516 let page_size: i64 = conn
4517 .pragma_query_value(None, "page_size", |r| r.get(0))
4518 .unwrap_or(0);
4519
4520 let freelist_count: i64 = conn
4521 .pragma_query_value(None, "freelist_count", |r| r.get(0))
4522 .unwrap_or(0);
4523
4524 let wal_checkpoint: &str = if journal_mode == "wal" {
4525 "not run (read-only diagnostics)"
4526 } else {
4527 "n/a (not WAL mode)"
4528 };
4529
4530 let integrity: String = conn
4531 .pragma_query_value(None, "quick_check", |r| r.get(0))
4532 .unwrap_or_else(|_| "failed".to_string());
4533
4534 let db_size_bytes = page_count * page_size;
4535 let db_size_mb = db_size_bytes as f64 / (1024.0 * 1024.0);
4536
4537 let mut tables = Vec::new();
4538 let mut table_bytes = 0usize;
4539 let mut tables_truncated = false;
4540 if let Ok(mut stmt) =
4541 conn.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
4542 && let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0))
4543 {
4544 for name in rows.flatten() {
4545 if tables.len() >= MAX_DB_HEALTH_TABLES
4546 || table_bytes.saturating_add(name.len()) > MAX_DB_HEALTH_TABLE_BYTES
4547 {
4548 tables_truncated = true;
4549 break;
4550 }
4551 table_bytes = table_bytes.saturating_add(name.len());
4552 let identifier = Self::quote_sqlite_identifier(&name);
4553 let count: i64 = conn
4554 .query_row(&format!("SELECT count(*) FROM {identifier}"), [], |r| {
4555 r.get(0)
4556 })
4557 .unwrap_or(0);
4558 tables.push(serde_json::json!({
4559 "name": name,
4560 "row_count": count,
4561 }));
4562 }
4563 }
4564 if timed_out.load(Ordering::Relaxed) {
4565 return Err(format!(
4566 "database diagnostics timed out after {} ms",
4567 DB_HEALTH_TIMEOUT.as_millis()
4568 ));
4569 }
4570
4571 Ok(serde_json::json!({
4572 "database": path_str,
4573 "journal_mode": journal_mode,
4574 "page_count": page_count,
4575 "page_size": page_size,
4576 "db_size_mb": (db_size_mb * 100.0).round() / 100.0,
4577 "freelist_count": freelist_count,
4578 "wal_checkpoint": wal_checkpoint,
4579 "integrity_check": integrity,
4580 "tables": tables,
4581 "tables_truncated": tables_truncated,
4582 }))
4583 })
4584 .await
4585 .map_err(|e| format!("db health task failed: {e}"))?
4586 }
4587
4588 fn check_bridge_version_once(&self) {
4589 if self.bridge_checked.swap(true, Ordering::Relaxed) {
4590 return;
4591 }
4592 let handler = self.clone();
4593 tokio::spawn(async move {
4594 match handler
4595 .eval_with_return_timeout(
4596 "window.__VICTAURI__?.version",
4597 None,
4598 std::time::Duration::from_secs(5),
4599 )
4600 .await
4601 {
4602 Ok(v) => {
4603 let v = v.trim_matches('"');
4604 if v == BRIDGE_VERSION {
4605 tracing::debug!("Bridge version verified: {v}");
4606 } else {
4607 tracing::warn!(
4608 "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
4609 );
4610 }
4611 }
4612 Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
4613 }
4614 });
4615 }
4616}
4617
4618const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
4619It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
4620(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
4621(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
4622\n\nBACKEND tools (direct Rust access, no webview needed): \
4623'app_info' (app config, directory paths, discovered databases, process info), \
4624'list_app_dir' (browse app data/config/log directories), \
4625'read_app_file' (read files from app directories), \
4626'query_db' (read-only SQLite queries with auto-discovery). \
4627\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
4628'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
4629capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
4630Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
4631capability/security auditing, database diagnostics, plugin state, child process enumeration, \
4632task tracking, and automatic Tauri event bus monitoring. \
4633'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
4634drops, and response corruption into Tauri commands at the Rust layer. \
4635'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
4636activity across IPC + DOM + console + network + window events into a coherent narrative. \
4637\n\nWEBVIEW tools: \
4638'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
4639'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
4640'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
4641\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
4642\n\nCOMPOUND tools with an 'action' parameter: \
4643'window' (get_state, list, manage, resize, move_to, set_title), \
4644'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
4645set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
4646get_events, events_between, get_replay, export, import, replay), \
4647'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
4648\n\nOTHER: verify_state, wait_for (incl. 'expression'/'event' conditions to await \
4649async backend work to true completion), assert_semantic, resolve_command, \
4650app_state (app-defined backend state probes), \
4651get_memory_stats, get_plugin_info, get_diagnostics.";
4652
4653impl ServerHandler for VictauriMcpHandler {
4654 fn get_info(&self) -> ServerInfo {
4655 ServerInfo::new(
4661 ServerCapabilities::builder()
4662 .enable_tools()
4663 .enable_resources()
4664 .build(),
4665 )
4666 .with_instructions(SERVER_INSTRUCTIONS)
4667 }
4668
4669 async fn list_tools(
4670 &self,
4671 _request: Option<PaginatedRequestParams>,
4672 _context: RequestContext<RoleServer>,
4673 ) -> Result<ListToolsResult, ErrorData> {
4674 let all_tools = Self::tool_router().list_all();
4675 let filtered: Vec<Tool> = all_tools
4676 .into_iter()
4677 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
4678 .collect();
4679 Ok(ListToolsResult {
4680 tools: filtered,
4681 ..Default::default()
4682 })
4683 }
4684
4685 async fn call_tool(
4686 &self,
4687 request: CallToolRequestParams,
4688 context: RequestContext<RoleServer>,
4689 ) -> Result<CallToolResult, ErrorData> {
4690 let tool_name: String = request.name.as_ref().to_owned();
4691 let args_value = serde_json::Value::Object(request.arguments.clone().unwrap_or_default());
4694 let capability = authz::canonical_capability(&tool_name, &args_value);
4695 if !self.state.privacy.is_call_allowed(&tool_name, &capability) {
4696 tracing::debug!(tool = %tool_name, capability = %capability, "tool call blocked by privacy config");
4697 return Ok(tool_disabled(&capability));
4698 }
4699 self.state
4700 .tool_invocations
4701 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4702 let start = std::time::Instant::now();
4703 tracing::debug!(tool = %tool_name, "tool invocation started");
4704 let ctx = ToolCallContext::new(self, request, context);
4705 let result = Self::tool_router().call(ctx).await;
4706 let elapsed = start.elapsed();
4707 tracing::debug!(
4708 tool = %tool_name,
4709 elapsed_ms = elapsed.as_millis() as u64,
4710 is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
4711 "tool invocation completed"
4712 );
4713
4714 if self.state.privacy.redaction_enabled {
4717 result.map(|mut r| {
4718 for item in &mut r.content {
4719 if let RawContent::Text(ref mut tc) = item.raw {
4720 tc.text = self.state.privacy.redact_output(&tc.text);
4721 }
4722 }
4723 r
4724 })
4725 } else {
4726 result
4727 }
4728 }
4729
4730 fn get_tool(&self, name: &str) -> Option<Tool> {
4731 if !self.state.privacy.is_tool_enabled(name) {
4732 return None;
4733 }
4734 Self::tool_router().get(name).cloned()
4735 }
4736
4737 async fn list_resources(
4738 &self,
4739 _request: Option<PaginatedRequestParams>,
4740 _context: RequestContext<RoleServer>,
4741 ) -> Result<ListResourcesResult, ErrorData> {
4742 Ok(ListResourcesResult {
4743 resources: vec![
4744 RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
4745 .with_description(
4746 "Live IPC call log — all commands invoked between frontend and backend",
4747 )
4748 .with_mime_type("application/json")
4749 .no_annotation(),
4750 RawResource::new(RESOURCE_URI_WINDOWS, "windows")
4751 .with_description(
4752 "Current state of all Tauri windows — position, size, visibility, focus",
4753 )
4754 .with_mime_type("application/json")
4755 .no_annotation(),
4756 RawResource::new(RESOURCE_URI_STATE, "state")
4757 .with_description(
4758 "Victauri plugin state — event count, registered commands, memory stats",
4759 )
4760 .with_mime_type("application/json")
4761 .no_annotation(),
4762 ],
4763 ..Default::default()
4764 })
4765 }
4766
4767 async fn read_resource(
4768 &self,
4769 request: ReadResourceRequestParams,
4770 _context: RequestContext<RoleServer>,
4771 ) -> Result<ReadResourceResult, ErrorData> {
4772 let uri = &request.uri;
4773 if let Some(cap) = resource_required_capability(uri.as_str())
4777 && !self.state.privacy.is_tool_enabled(cap)
4778 {
4779 return Err(ErrorData::invalid_request(
4780 format!("resource {uri} is not permitted by the current privacy configuration"),
4781 None,
4782 ));
4783 }
4784 let json = match uri.as_str() {
4785 RESOURCE_URI_IPC_LOG => {
4786 let code = trimmed_log_js("window.__VICTAURI__?.getIpcLog()", DEFAULT_LOG_LIMIT);
4793 if let Ok(json) = self.eval_with_return(&code, None).await {
4794 json
4795 } else {
4796 let calls = self.state.event_log.ipc_calls();
4797 serde_json::to_string_pretty(&calls)
4798 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4799 }
4800 }
4801 RESOURCE_URI_WINDOWS => {
4802 let states = self.bridge.get_window_states(None);
4803 serde_json::to_string_pretty(&states)
4804 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4805 }
4806 RESOURCE_URI_STATE => {
4807 let state_json = serde_json::json!({
4808 "events_captured": self.state.event_log.len(),
4809 "commands_registered": self.state.registry.count(),
4810 "memory": crate::memory::current_stats(),
4811 "port": self.state.port.load(Ordering::Relaxed),
4812 });
4813 serde_json::to_string_pretty(&state_json)
4814 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4815 }
4816 _ => {
4817 return Err(ErrorData::resource_not_found(
4818 format!("unknown resource: {uri}"),
4819 None,
4820 ));
4821 }
4822 };
4823
4824 let json = if self.state.privacy.redaction_enabled {
4825 self.state.privacy.redact_output(&json)
4826 } else {
4827 json
4828 };
4829
4830 Ok(ReadResourceResult::new(vec![ResourceContents::text(
4831 json, uri,
4832 )]))
4833 }
4834
4835 async fn subscribe(
4836 &self,
4837 request: SubscribeRequestParams,
4838 _context: RequestContext<RoleServer>,
4839 ) -> Result<(), ErrorData> {
4840 let uri = &request.uri;
4841 if let Some(cap) = resource_required_capability(uri.as_str())
4844 && !self.state.privacy.is_tool_enabled(cap)
4845 {
4846 return Err(ErrorData::invalid_request(
4847 format!("resource {uri} is not permitted by the current privacy configuration"),
4848 None,
4849 ));
4850 }
4851 match uri.as_str() {
4852 RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
4853 self.subscriptions.lock().await.insert(uri.clone());
4854 tracing::info!("Client subscribed to resource: {uri}");
4855 Ok(())
4856 }
4857 _ => Err(ErrorData::resource_not_found(
4858 format!("unknown resource: {uri}"),
4859 None,
4860 )),
4861 }
4862 }
4863
4864 async fn unsubscribe(
4865 &self,
4866 request: UnsubscribeRequestParams,
4867 _context: RequestContext<RoleServer>,
4868 ) -> Result<(), ErrorData> {
4869 self.subscriptions.lock().await.remove(&request.uri);
4870 tracing::info!("Client unsubscribed from resource: {}", request.uri);
4871 Ok(())
4872 }
4873}
4874
4875fn trimmed_log_js(source_expr: &str, limit: usize) -> String {
4882 let mb = MAX_LOG_FIELD_BYTES;
4883 format!(
4884 r"return (function() {{
4885 var MB = {mb};
4886 function trimField(v) {{
4887 if (typeof v === 'string') {{
4888 return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
4889 }}
4890 if (v && typeof v === 'object') {{
4891 var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }}
4892 if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }}
4893 }}
4894 return v;
4895 }}
4896 function trimEntry(e) {{
4897 if (e == null || typeof e !== 'object') return e;
4898 var out = Array.isArray(e) ? [] : {{}};
4899 for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) out[k] = trimField(e[k]); }}
4900 return out;
4901 }}
4902 var arr = {source_expr} || [];
4903 if (arr.length > {limit}) arr = arr.slice(-{limit});
4904 return arr.map(trimEntry);
4905 }})()"
4906 )
4907}
4908
4909fn unwrap_eval_envelope(raw: String) -> Result<String, String> {
4920 if let Ok(envelope) = serde_json::from_str::<serde_json::Value>(&raw) {
4921 if let Some(err) = envelope.get("__victauri_err") {
4922 return Err(format!(
4923 "JavaScript error: {}",
4924 err.as_str().unwrap_or("unknown error")
4925 ));
4926 }
4927 if envelope.get("__victauri_ok").is_some() {
4928 let js_type = envelope
4929 .get("__victauri_type")
4930 .and_then(|t| t.as_str())
4931 .unwrap_or("value");
4932 return match js_type {
4933 "undefined" => Ok("undefined".to_string()),
4934 "null" => Ok("null".to_string()),
4935 _ => Ok(serde_json::to_string(&envelope["__victauri_ok"])
4936 .unwrap_or_else(|_| "null".to_string())),
4937 };
4938 }
4939 }
4940 if let Some(after) = raw.strip_prefix(r#"{"__victauri_ok":"#)
4946 && let Some(idx) = after.rfind(r#","__victauri_type":"#)
4947 {
4948 return Ok(after[..idx].to_string());
4949 }
4950 if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) {
4951 let msg = after.trim_end_matches('}').trim_matches('"');
4952 return Err(format!("JavaScript error: {msg}"));
4953 }
4954 Ok(raw)
4955}
4956
4957const STMT_STARTS: &[&str] = &[
4959 "return ",
4960 "return;",
4961 "return\n",
4962 "return\t",
4963 "if ",
4964 "if(",
4965 "for ",
4966 "for(",
4967 "while ",
4968 "while(",
4969 "switch ",
4970 "switch(",
4971 "try ",
4972 "try{",
4973 "const ",
4974 "let ",
4975 "var ",
4976 "function ",
4977 "function(",
4978 "function*",
4979 "class ",
4980 "throw ",
4981 "do ",
4982 "do{",
4983 "{",
4984 "async function",
4985 "debugger",
4986];
4987
4988#[derive(PartialEq, Clone, Copy)]
4990enum ScanState {
4991 Code,
4992 SingleQuote,
4993 DoubleQuote,
4994 Template,
4995}
4996
4997fn should_prepend_return(code: &str) -> bool {
5008 use ScanState::{Code, DoubleQuote, SingleQuote, Template};
5009
5010 let code = code.trim();
5011 if code.is_empty() {
5012 return false;
5013 }
5014
5015 if STMT_STARTS.iter().any(|k| code.starts_with(k)) {
5016 return false;
5017 }
5018
5019 let bytes = code.as_bytes();
5020 let mut i = 0;
5021 let mut depth: i32 = 0;
5022 let mut state = ScanState::Code;
5023
5024 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$';
5025 let is_return_token = |i: usize| -> bool {
5027 let prev_ok = i == 0 || !is_ident(bytes[i - 1]);
5028 prev_ok
5029 && code[i..].starts_with("return")
5030 && bytes.get(i + 6).copied().is_none_or(|b| !is_ident(b))
5031 };
5032
5033 while i < bytes.len() {
5034 let c = bytes[i];
5035 match state {
5036 Code => match c {
5037 b'\'' => state = SingleQuote,
5038 b'"' => state = DoubleQuote,
5039 b'`' => state = Template,
5040 b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => {
5041 while i < bytes.len() && bytes[i] != b'\n' {
5042 i += 1;
5043 }
5044 continue;
5045 }
5046 b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
5047 i += 2;
5048 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
5049 i += 1;
5050 }
5051 i += 2;
5052 continue;
5053 }
5054 b'(' | b'[' | b'{' => depth += 1,
5055 b')' | b']' | b'}' => depth -= 1,
5056 b';' if depth <= 0 && !code[i + 1..].trim().is_empty() => return false,
5058 b'r' if depth <= 0 && is_return_token(i) => return false,
5060 _ => {}
5061 },
5062 SingleQuote => {
5063 if c == b'\\' {
5064 i += 1;
5065 } else if c == b'\'' {
5066 state = Code;
5067 }
5068 }
5069 DoubleQuote => {
5070 if c == b'\\' {
5071 i += 1;
5072 } else if c == b'"' {
5073 state = Code;
5074 }
5075 }
5076 Template => {
5077 if c == b'\\' {
5078 i += 1;
5079 } else if c == b'`' {
5080 state = Code;
5081 }
5082 }
5083 }
5084 i += 1;
5085 }
5086
5087 true
5088}
5089
5090#[cfg(test)]
5091mod prop_tests {
5092 use super::should_prepend_return;
5097 use proptest::prelude::*;
5098
5099 fn ident() -> impl Strategy<Value = String> {
5101 prop_oneof![
5102 Just("a".to_string()),
5103 Just("x".to_string()),
5104 Just("foo".to_string()),
5105 Just("window.x".to_string()),
5106 Just("document.title".to_string()),
5107 Just("obj.prop".to_string()),
5108 Just("arr[0]".to_string()),
5109 Just("localStorage".to_string()),
5110 ]
5111 }
5112
5113 fn bare_expr() -> impl Strategy<Value = String> {
5116 prop_oneof![
5117 ident(),
5118 (ident(), ident()).prop_map(|(a, b)| format!("{a} + {b}")),
5119 (ident(), ident()).prop_map(|(a, b)| format!("{a}({b})")),
5120 ident().prop_map(|a| format!("{a}.length")),
5121 any::<u16>().prop_map(|n| n.to_string()),
5122 ]
5123 }
5124
5125 proptest! {
5126 #[test]
5129 fn never_panics_on_arbitrary_input(s in ".{0,256}") {
5130 let _ = should_prepend_return(&s);
5131 }
5132
5133 #[test]
5135 fn bare_expressions_are_prepended(e in bare_expr()) {
5136 prop_assert!(should_prepend_return(&e), "bare expr not prepended: {e:?}");
5137 }
5138
5139 #[test]
5142 fn semicolon_multistatement_with_return_never_prepended(
5143 setup in bare_expr(), ret in bare_expr()
5144 ) {
5145 let code = format!("{setup}; return {ret}");
5146 prop_assert!(!should_prepend_return(&code), "would corrupt: {code:?}");
5147 }
5148
5149 #[test]
5151 fn newline_explicit_return_never_prepended(pre in bare_expr(), ret in bare_expr()) {
5152 let code = format!("{pre}\nreturn {ret}");
5153 prop_assert!(!should_prepend_return(&code), "explicit return prepended: {code:?}");
5154 }
5155
5156 #[test]
5159 fn semicolons_and_return_inside_strings_are_ignored(inner in "[a-z0-9;= ]{0,24}") {
5160 let code = format!("'do;not;split return {inner}'");
5162 prop_assert!(should_prepend_return(&code), "string literal mis-split: {code:?}");
5163 }
5164 }
5165}
5166
5167#[cfg(test)]
5168mod tests {
5169 use super::*;
5170
5171 #[cfg(feature = "sqlite")]
5172 #[test]
5173 fn database_path_resolution_rejects_lexical_escape() {
5174 let dir = tempfile::tempdir().unwrap();
5175 let root = dir.path().join("allowed");
5176 std::fs::create_dir(&root).unwrap();
5177 std::fs::File::create(dir.path().join("outside.db")).unwrap();
5178
5179 let err =
5180 VictauriMcpHandler::resolve_existing_db_path(&[root], "../outside.db").unwrap_err();
5181 assert!(err.contains("path traversal"), "unexpected error: {err}");
5182 }
5183
5184 #[cfg(feature = "sqlite")]
5185 #[test]
5186 fn database_path_resolution_accepts_contained_nested_file() {
5187 let dir = tempfile::tempdir().unwrap();
5188 let root = dir.path().join("allowed");
5189 let nested = root.join("nested");
5190 std::fs::create_dir_all(&nested).unwrap();
5191 let db = nested.join("app.db");
5192 std::fs::File::create(&db).unwrap();
5193
5194 let resolved =
5195 VictauriMcpHandler::resolve_existing_db_path(&[root], "nested/app.db").unwrap();
5196 assert_eq!(resolved, std::fs::canonicalize(&db).unwrap());
5199 }
5200
5201 #[cfg(all(feature = "sqlite", unix))]
5202 #[test]
5203 fn database_path_resolution_rejects_symlink_escape() {
5204 use std::os::unix::fs::symlink;
5205
5206 let dir = tempfile::tempdir().unwrap();
5207 let root = dir.path().join("allowed");
5208 std::fs::create_dir(&root).unwrap();
5209 let outside = dir.path().join("outside.db");
5210 std::fs::File::create(&outside).unwrap();
5211 symlink(&outside, root.join("linked.db")).unwrap();
5212
5213 let err = VictauriMcpHandler::resolve_existing_db_path(&[root], "linked.db").unwrap_err();
5214 assert!(err.contains("path traversal"), "unexpected error: {err}");
5215 }
5216
5217 #[cfg(feature = "sqlite")]
5218 #[test]
5219 fn sqlite_identifier_quoting_handles_hostile_table_names() {
5220 let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap();
5221 let conn = rusqlite::Connection::open(file.path()).unwrap();
5222 let name = "odd\"] table";
5223 let identifier = VictauriMcpHandler::quote_sqlite_identifier(name);
5224 conn.execute_batch(&format!(
5225 "CREATE TABLE {identifier} (id INTEGER); INSERT INTO {identifier} VALUES (1);"
5226 ))
5227 .unwrap();
5228 let count: i64 = conn
5229 .query_row(&format!("SELECT count(*) FROM {identifier}"), [], |row| {
5230 row.get(0)
5231 })
5232 .unwrap();
5233 assert_eq!(count, 1);
5234 }
5235
5236 #[test]
5237 fn env_filter_drops_secrets_keeps_safe() {
5238 assert!(is_safe_env_key("HOME"));
5240 assert!(is_safe_env_key("LANG"));
5241 assert!(is_safe_env_key("TAURI_ENV_PLATFORM"));
5242 assert!(is_safe_env_key("VICTAURI_PORT"));
5243 assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY"));
5245 assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"));
5246 assert!(!is_safe_env_key("VICTAURI_AUTH_TOKEN"));
5247 assert!(!is_safe_env_key("VICTAURI_API_KEY"));
5248 assert!(!is_safe_env_key("AWS_SECRET_ACCESS_KEY"));
5250 assert!(!is_safe_env_key("RANDOM_VAR"));
5251 assert!(!is_safe_env_key("TAURI_CUSTOM_THING"));
5254 assert!(!is_safe_env_key("VICTAURI_DB_DSN"));
5257 assert!(!is_safe_env_key("VICTAURI_SIGNING_PASSPHRASE"));
5258 assert!(!is_safe_env_key("VICTAURI_GH_PAT"));
5259 assert!(!is_safe_env_key("VICTAURI_JWT"));
5260 assert!(!is_safe_env_key("VICTAURI_SESSION_ID"));
5261 }
5262
5263 #[test]
5264 fn prepend_return_bare_expressions() {
5265 assert!(should_prepend_return("document.title"));
5266 assert!(should_prepend_return("5 + 5"));
5267 assert!(should_prepend_return("\"justexpr\""));
5268 assert!(should_prepend_return("await fetch('/x')"));
5269 assert!(should_prepend_return(
5270 "document.querySelectorAll('a').length"
5271 ));
5272 assert!(should_prepend_return("x ? a : b"));
5273 assert!(should_prepend_return("document.title;"));
5275 assert!(should_prepend_return("'a;b;c'"));
5277 assert!(should_prepend_return("\"x;y\".length"));
5278 assert!(should_prepend_return("(()=>{window.x=5; return 'ok'})()"));
5280 }
5281
5282 #[test]
5283 fn no_prepend_for_statement_blocks() {
5284 assert!(!should_prepend_return(
5286 "localStorage.setItem('k','v'); return localStorage.getItem('k')"
5287 ));
5288 assert!(!should_prepend_return(
5289 "window.scrollTo(0,50); return window.scrollY"
5290 ));
5291 assert!(!should_prepend_return("console.log('x'); return 123"));
5292 assert!(!should_prepend_return("window.__z=7; return 'ok'"));
5293 assert!(!should_prepend_return("window.x = 5\nreturn window.x"));
5295 }
5296
5297 #[test]
5298 fn no_prepend_for_statement_keywords() {
5299 assert!(!should_prepend_return("return 42"));
5300 assert!(!should_prepend_return("const x = 1; return x"));
5301 assert!(!should_prepend_return("let y = 2"));
5302 assert!(!should_prepend_return("var z = 3"));
5303 assert!(!should_prepend_return("if (x) { return 1 }"));
5304 assert!(!should_prepend_return("for (const x of y) doThing(x)"));
5305 assert!(!should_prepend_return("throw new Error('x')"));
5306 assert!(!should_prepend_return("function f(){}"));
5307 assert!(!should_prepend_return("{ a: 1 }")); }
5309
5310 #[test]
5311 fn empty_code_no_prepend() {
5312 assert!(!should_prepend_return(""));
5313 assert!(!should_prepend_return(" "));
5314 }
5315
5316 #[test]
5317 fn envelope_unwrap_value() {
5318 assert_eq!(
5319 unwrap_eval_envelope(r#"{"__victauri_ok":"4DA","__victauri_type":"value"}"#.into()),
5320 Ok("\"4DA\"".to_string())
5321 );
5322 assert_eq!(
5323 unwrap_eval_envelope(r#"{"__victauri_ok":42,"__victauri_type":"value"}"#.into()),
5324 Ok("42".to_string())
5325 );
5326 }
5327
5328 #[test]
5329 fn envelope_unwrap_undefined_null() {
5330 assert_eq!(
5331 unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#.into()),
5332 Ok("undefined".to_string())
5333 );
5334 assert_eq!(
5335 unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"null"}"#.into()),
5336 Ok("null".to_string())
5337 );
5338 }
5339
5340 #[test]
5341 fn envelope_unwrap_error() {
5342 let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into());
5343 assert!(r.unwrap_err().contains("boom"));
5344 }
5345
5346 #[test]
5347 fn envelope_unwrap_deeply_nested_does_not_leak() {
5348 let mut value = String::from("0");
5352 for _ in 0..300 {
5353 value = format!("{{\"n\":{value}}}");
5354 }
5355 let raw = format!(r#"{{"__victauri_ok":{value},"__victauri_type":"value"}}"#);
5356 let out = unwrap_eval_envelope(raw).unwrap();
5357 assert!(
5358 out.starts_with(r#"{"n":"#),
5359 "deep value should be unwrapped, got: {}",
5360 &out[..out.len().min(40)]
5361 );
5362 assert!(
5363 !out.contains("__victauri_ok"),
5364 "envelope must not leak into the result"
5365 );
5366 }
5367
5368 #[test]
5369 fn js_string_simple() {
5370 assert_eq!(js_string("hello"), "\"hello\"");
5371 }
5372
5373 #[test]
5374 fn js_string_single_quotes() {
5375 let result = js_string("it's a test");
5376 assert!(result.contains("it's a test"));
5377 }
5378
5379 #[test]
5380 fn js_string_double_quotes() {
5381 let result = js_string(r#"say "hello""#);
5382 assert!(result.contains(r#"\""#));
5383 }
5384
5385 #[test]
5386 fn js_string_backslashes() {
5387 let result = js_string(r"path\to\file");
5388 assert!(result.contains(r"\\"));
5389 }
5390
5391 #[test]
5392 fn js_string_newlines_and_tabs() {
5393 let result = js_string("line1\nline2\ttab");
5394 assert!(result.contains(r"\n"));
5395 assert!(result.contains(r"\t"));
5396 assert!(!result.contains('\n'));
5397 }
5398
5399 #[test]
5400 fn js_string_null_bytes() {
5401 let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
5402 let result = js_string(&input);
5403 assert!(result.contains("\\u0000"));
5405 assert!(!result.contains('\0'));
5406 }
5407
5408 #[test]
5409 fn js_string_template_literal_injection() {
5410 let result = js_string("`${alert(1)}`");
5411 assert!(result.starts_with('"'));
5414 assert!(result.ends_with('"'));
5415 }
5416
5417 #[test]
5418 fn js_string_unicode_separators() {
5419 let result = js_string("a\u{2028}b\u{2029}c");
5424 let decoded: String = serde_json::from_str(&result).unwrap();
5426 assert_eq!(decoded, "a\u{2028}b\u{2029}c");
5427 }
5428
5429 #[test]
5430 fn js_string_empty() {
5431 assert_eq!(js_string(""), "\"\"");
5432 }
5433
5434 #[test]
5435 fn js_string_html_script_close() {
5436 let result = js_string("</script><img onerror=alert(1)>");
5438 assert!(result.starts_with('"'));
5439 let decoded: String = serde_json::from_str(&result).unwrap();
5441 assert_eq!(decoded, "</script><img onerror=alert(1)>");
5442 }
5443
5444 #[test]
5445 fn js_string_very_long() {
5446 let long = "a".repeat(100_000);
5447 let result = js_string(&long);
5448 assert!(result.len() >= 100_002); }
5450
5451 #[test]
5454 fn url_allows_http() {
5455 assert!(validate_url("http://example.com", false).is_ok());
5456 }
5457
5458 #[test]
5459 fn url_allows_https() {
5460 assert!(validate_url("https://example.com/path?q=1", false).is_ok());
5461 }
5462
5463 #[test]
5464 fn url_allows_http_localhost() {
5465 assert!(validate_url("http://localhost:3000", false).is_ok());
5466 }
5467
5468 #[test]
5469 fn url_blocks_file_by_default() {
5470 let err = validate_url("file:///etc/passwd", false).unwrap_err();
5471 assert!(err.contains("file"), "error should mention the file scheme");
5472 }
5473
5474 #[test]
5475 fn url_allows_file_when_opted_in() {
5476 assert!(validate_url("file:///tmp/test.html", true).is_ok());
5477 }
5478
5479 #[test]
5480 fn url_blocks_javascript() {
5481 assert!(validate_url("javascript:alert(1)", false).is_err());
5482 }
5483
5484 #[test]
5485 fn url_blocks_javascript_case_insensitive() {
5486 assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
5487 }
5488
5489 #[test]
5490 fn url_blocks_data_scheme() {
5491 assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
5492 }
5493
5494 #[test]
5495 fn url_blocks_vbscript() {
5496 assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
5497 }
5498
5499 #[test]
5500 fn url_rejects_invalid() {
5501 assert!(validate_url("not a url at all", false).is_err());
5502 }
5503
5504 #[test]
5505 fn url_strips_control_chars() {
5506 let input = format!("http://example{}com", '\0');
5508 assert!(validate_url(&input, false).is_ok());
5509 }
5510
5511 #[test]
5514 fn css_color_valid_hex() {
5515 assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
5516 assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
5517 assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
5518 }
5519
5520 #[test]
5521 fn css_color_valid_rgb() {
5522 assert_eq!(
5523 sanitize_css_color("rgb(255, 0, 0)").unwrap(),
5524 "rgb(255, 0, 0)"
5525 );
5526 assert_eq!(
5527 sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
5528 "rgba(0, 0, 0, 0.5)"
5529 );
5530 }
5531
5532 #[test]
5533 fn css_color_valid_named() {
5534 assert_eq!(sanitize_css_color("red").unwrap(), "red");
5535 assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
5536 }
5537
5538 #[test]
5539 fn css_color_valid_hsl() {
5540 assert_eq!(
5541 sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
5542 "hsl(120, 50%, 50%)"
5543 );
5544 }
5545
5546 #[test]
5547 fn css_color_rejects_too_long() {
5548 let long = "a".repeat(101);
5549 assert!(sanitize_css_color(&long).is_err());
5550 }
5551
5552 #[test]
5553 fn css_color_rejects_backslash_escapes() {
5554 assert!(sanitize_css_color(r"red\00").is_err());
5555 assert!(sanitize_css_color(r"\72\65\64").is_err());
5556 }
5557
5558 #[test]
5559 fn css_color_rejects_url_injection() {
5560 assert!(sanitize_css_color("url(http://evil.com)").is_err());
5561 assert!(sanitize_css_color("URL(http://evil.com)").is_err());
5562 }
5563
5564 #[test]
5565 fn css_color_rejects_expression_injection() {
5566 assert!(sanitize_css_color("expression(alert(1))").is_err());
5567 assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
5568 }
5569
5570 #[test]
5571 fn css_color_rejects_import() {
5572 assert!(sanitize_css_color("@import url(evil.css)").is_err());
5573 }
5574
5575 #[test]
5576 fn css_color_rejects_semicolons_and_braces() {
5577 assert!(sanitize_css_color("red; background: url(evil)").is_err());
5578 assert!(sanitize_css_color("red} body { color: blue").is_err());
5579 }
5580
5581 #[test]
5582 fn css_color_rejects_special_chars() {
5583 assert!(sanitize_css_color("red<script>").is_err());
5584 assert!(sanitize_css_color("red\"onload=alert").is_err());
5585 assert!(sanitize_css_color("red'onclick=alert").is_err());
5586 }
5587
5588 #[test]
5589 fn css_color_trims_whitespace() {
5590 assert_eq!(sanitize_css_color(" red ").unwrap(), "red");
5591 }
5592
5593 #[test]
5594 fn css_color_empty_string() {
5595 assert_eq!(sanitize_css_color("").unwrap(), "");
5596 }
5597}
5598
5599#[cfg(test)]
5608mod authz_dispatch_tests {
5609 use super::*;
5610 use crate::bridge::WebviewBridge;
5611 use crate::privacy::PrivacyConfig;
5612 use std::collections::{HashMap, HashSet};
5613 use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
5614
5615 struct RejectingBridge;
5619
5620 impl WebviewBridge for RejectingBridge {
5621 fn eval_webview(&self, _label: Option<&str>, _script: &str) -> Result<(), String> {
5622 Err("eval rejected in authz dispatch test".to_string())
5623 }
5624 fn get_window_states(&self, _label: Option<&str>) -> Vec<WindowState> {
5625 Vec::new()
5626 }
5627 fn list_window_labels(&self) -> Vec<String> {
5628 Vec::new()
5629 }
5630 fn get_native_handle(&self, _label: Option<&str>) -> Result<isize, String> {
5631 Err("no handle".to_string())
5632 }
5633 fn manage_window(&self, _label: Option<&str>, _action: &str) -> Result<String, String> {
5634 Err("no window".to_string())
5635 }
5636 fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
5637 Ok(())
5638 }
5639 fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
5640 Ok(())
5641 }
5642 fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
5643 Ok(())
5644 }
5645 }
5646
5647 fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
5648 Arc::new(VictauriState {
5649 event_log: EventLog::new(1000),
5650 registry: CommandRegistry::new(),
5651 port: std::sync::atomic::AtomicU16::new(0),
5652 pending_evals: Arc::new(Mutex::new(HashMap::new())),
5653 recorder: EventRecorder::new(1000),
5654 privacy,
5655 eval_timeout: std::time::Duration::from_millis(100),
5656 shutdown_tx: tokio::sync::watch::channel(false).0,
5657 started_at: std::time::Instant::now(),
5658 tool_invocations: std::sync::atomic::AtomicU64::new(0),
5659 allow_file_navigation: false,
5660 command_timings: crate::introspection::CommandTimings::new(),
5661 fault_registry: crate::introspection::FaultRegistry::new(),
5662 contract_store: crate::introspection::ContractStore::new(),
5663 startup_timeline: crate::introspection::StartupTimeline::new(),
5664 event_bus: crate::introspection::EventBusMonitor::default(),
5665 task_tracker: crate::introspection::TaskTracker::new(),
5666 bridge_ready: std::sync::atomic::AtomicBool::new(true),
5667 bridge_notify: tokio::sync::Notify::new(),
5668 db_search_paths: Vec::new(),
5669 screencast: Arc::new(crate::screencast::Screencast::default()),
5670 probes: crate::introspection::AppStateProbes::default(),
5671 })
5672 }
5673
5674 fn handler(privacy: PrivacyConfig) -> VictauriMcpHandler {
5675 VictauriMcpHandler::new(state_with(privacy), Arc::new(RejectingBridge))
5676 }
5677
5678 fn is_privacy_blocked(r: &CallToolResult) -> bool {
5680 r.is_error == Some(true)
5681 && r.content.iter().any(|c| {
5682 matches!(&c.raw, RawContent::Text(t)
5683 if t.text.contains("disabled by privacy configuration"))
5684 })
5685 }
5686
5687 async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
5688 match h.execute_tool(tool, args).await {
5689 Ok(r) => r,
5690 Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
5691 }
5692 }
5693
5694 #[tokio::test]
5697 async fn observe_blocks_mutations_and_eval_through_dispatch() {
5698 let h = handler(crate::privacy::observe_privacy_config());
5699 let blocked: &[(&str, serde_json::Value)] = &[
5700 ("eval_js", serde_json::json!({"code": "1"})),
5701 (
5702 "wait_for",
5703 serde_json::json!({"condition": "expression", "value": "true"}),
5704 ),
5705 ("screenshot", serde_json::json!({})),
5706 ("invoke_command", serde_json::json!({"command": "greet"})),
5707 ("verify_state", serde_json::json!({"frontend_expr": "1"})),
5708 (
5709 "assert_semantic",
5710 serde_json::json!({"expression": "1", "condition": "truthy"}),
5711 ),
5712 (
5713 "interact",
5714 serde_json::json!({"action": "click", "ref_id": "e1"}),
5715 ),
5716 (
5717 "input",
5718 serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
5719 ),
5720 (
5721 "storage",
5722 serde_json::json!({"action": "set", "key": "k", "value": "v"}),
5723 ),
5724 (
5725 "storage",
5726 serde_json::json!({"action": "delete", "key": "k"}),
5727 ),
5728 (
5729 "window",
5730 serde_json::json!({"action": "manage", "manage_action": "close"}),
5731 ),
5732 (
5733 "window",
5734 serde_json::json!({"action": "set_title", "title": "x"}),
5735 ),
5736 (
5737 "navigate",
5738 serde_json::json!({"action": "go_to", "url": "https://e.com"}),
5739 ),
5740 (
5741 "css",
5742 serde_json::json!({"action": "inject", "css": "body{}"}),
5743 ),
5744 ("route", serde_json::json!({"action": "clear_all"})),
5745 ("recording", serde_json::json!({"action": "start"})),
5746 ("recording", serde_json::json!({"action": "replay"})),
5747 ("logs", serde_json::json!({"action": "clear"})),
5748 (
5749 "fault",
5750 serde_json::json!({"action": "inject", "command": "x", "fault_type": "error"}),
5751 ),
5752 (
5753 "introspect",
5754 serde_json::json!({"action": "command_timings"}),
5755 ),
5756 ];
5757 for (tool, args) in blocked {
5758 let r = call(&h, tool, args.clone()).await;
5759 assert!(
5760 is_privacy_blocked(&r),
5761 "Observe must block {tool} {args} at dispatch, got: {:?}",
5762 r.content
5763 );
5764 }
5765 }
5766
5767 #[tokio::test]
5768 async fn observe_allows_read_only_through_dispatch() {
5769 let h = handler(crate::privacy::observe_privacy_config());
5770 let allowed: &[(&str, serde_json::Value)] = &[
5773 ("get_registry", serde_json::json!({})),
5774 ("get_memory_stats", serde_json::json!({})),
5775 ("window", serde_json::json!({"action": "list"})),
5776 ("logs", serde_json::json!({"action": "ipc"})),
5777 (
5778 "inspect",
5779 serde_json::json!({"action": "get_styles", "ref_id": "e1"}),
5780 ),
5781 ];
5782 for (tool, args) in allowed {
5783 let r = call(&h, tool, args.clone()).await;
5784 assert!(
5785 !is_privacy_blocked(&r),
5786 "Observe must allow {tool} {args} at dispatch (blocked unexpectedly)"
5787 );
5788 }
5789 }
5790
5791 #[tokio::test]
5794 async fn test_profile_dispatch_boundaries() {
5795 let h = handler(crate::privacy::test_privacy_config());
5796 for (tool, args) in [
5798 (
5799 "interact",
5800 serde_json::json!({"action": "click", "ref_id": "e1"}),
5801 ),
5802 (
5803 "input",
5804 serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
5805 ),
5806 (
5807 "storage",
5808 serde_json::json!({"action": "set", "key": "k", "value": "v"}),
5809 ),
5810 ("navigate", serde_json::json!({"action": "go_back"})),
5811 ("recording", serde_json::json!({"action": "start"})),
5812 ("logs", serde_json::json!({"action": "clear"})),
5813 ] {
5814 let r = call(&h, tool, args.clone()).await;
5815 assert!(!is_privacy_blocked(&r), "Test must allow {tool} {args}");
5816 }
5817 for (tool, args) in [
5819 ("eval_js", serde_json::json!({"code": "1"})),
5820 (
5821 "wait_for",
5822 serde_json::json!({"condition": "expression", "value": "true"}),
5823 ),
5824 ("verify_state", serde_json::json!({"frontend_expr": "1"})),
5825 (
5826 "navigate",
5827 serde_json::json!({"action": "go_to", "url": "https://e.com"}),
5828 ),
5829 ("recording", serde_json::json!({"action": "replay"})),
5830 (
5831 "route",
5832 serde_json::json!({"action": "add", "pattern": "x"}),
5833 ),
5834 ("css", serde_json::json!({"action": "inject", "css": "x"})),
5835 (
5836 "window",
5837 serde_json::json!({"action": "set_title", "title": "x"}),
5838 ),
5839 ] {
5840 let r = call(&h, tool, args.clone()).await;
5841 assert!(is_privacy_blocked(&r), "Test must block {tool} {args}");
5842 }
5843 }
5844
5845 #[tokio::test]
5850 async fn disabling_bare_compound_tool_blocks_all_actions() {
5851 let cfg = PrivacyConfig {
5852 disabled_tools: HashSet::from(["recording".to_string()]),
5853 ..Default::default()
5854 }; let h = handler(cfg);
5856 for action in ["start", "stop", "replay", "import", "export"] {
5857 let r = call(&h, "recording", serde_json::json!({"action": action})).await;
5858 assert!(
5859 is_privacy_blocked(&r),
5860 "disabling bare `recording` must block recording.{action}"
5861 );
5862 }
5863 }
5864
5865 #[tokio::test]
5866 async fn disabling_specific_action_is_honored_at_dispatch() {
5867 let cfg = PrivacyConfig {
5871 disabled_tools: HashSet::from([
5872 "route.clear".to_string(),
5873 "route.clear_all".to_string(),
5874 ]),
5875 ..Default::default()
5876 }; let h = handler(cfg);
5878
5879 let blocked = call(&h, "route", serde_json::json!({"action": "clear", "id": 1})).await;
5880 assert!(is_privacy_blocked(&blocked), "route.clear must be blocked");
5881 let blocked_all = call(&h, "route", serde_json::json!({"action": "clear_all"})).await;
5882 assert!(
5883 is_privacy_blocked(&blocked_all),
5884 "route.clear_all must be blocked"
5885 );
5886
5887 let allowed = call(&h, "route", serde_json::json!({"action": "list"})).await;
5889 assert!(
5890 !is_privacy_blocked(&allowed),
5891 "route.list must remain allowed"
5892 );
5893 }
5894
5895 #[tokio::test]
5901 async fn full_control_allows_everything_at_dispatch() {
5902 let h = handler(PrivacyConfig::default());
5903 for (tool, args) in [
5904 ("recording", serde_json::json!({"action": "replay"})),
5905 ("route", serde_json::json!({"action": "clear_all"})),
5906 ("eval_js", serde_json::json!({"code": "1"})),
5907 ("fault", serde_json::json!({"action": "list"})),
5908 ] {
5909 let r = call(&h, tool, args.clone()).await;
5910 assert!(
5911 !is_privacy_blocked(&r),
5912 "FullControl must allow {tool} {args}"
5913 );
5914 }
5915 }
5916}
5917
5918#[cfg(test)]
5932mod command_policy_dispatch_tests {
5933 use super::*;
5934 use crate::bridge::WebviewBridge;
5935 use crate::privacy::PrivacyConfig;
5936 use serde_json::json;
5937 use std::collections::{HashMap, HashSet};
5938 use std::sync::Mutex as StdMutex;
5939 use victauri_core::{
5940 AppEvent, CommandRegistry, EventLog, EventRecorder, IpcCall, IpcResult, RecordedEvent,
5941 RecordedSession, WindowState,
5942 };
5943
5944 #[derive(Clone, Default)]
5954 struct RecordingBridge {
5955 scripts: Arc<StdMutex<Vec<String>>>,
5956 pending_evals: Option<crate::PendingCallbacks>,
5957 }
5958
5959 fn extract_probe_id(script: &str) -> Option<String> {
5961 let start = script.find("id:\"")? + 4;
5962 script.get(start..start + 36).map(str::to_string)
5963 }
5964
5965 impl RecordingBridge {
5966 fn answering(pending_evals: crate::PendingCallbacks) -> Self {
5970 Self {
5971 scripts: Arc::default(),
5972 pending_evals: Some(pending_evals),
5973 }
5974 }
5975
5976 fn invoked(&self, command: &str) -> bool {
5978 let needle = format!("invoke({}", js_string(command));
5979 self.scripts
5980 .lock()
5981 .unwrap_or_else(std::sync::PoisonError::into_inner)
5982 .iter()
5983 .any(|s| s.contains(&needle))
5984 }
5985 }
5986
5987 impl WebviewBridge for RecordingBridge {
5988 fn eval_webview(&self, _label: Option<&str>, script: &str) -> Result<(), String> {
5989 self.scripts
5990 .lock()
5991 .unwrap_or_else(std::sync::PoisonError::into_inner)
5992 .push(script.to_string());
5993 if let Some(pending) = &self.pending_evals
5999 && script.contains("probe_ok")
6000 && let Some(id) = extract_probe_id(script)
6001 {
6002 let pending = pending.clone();
6003 std::thread::spawn(move || {
6004 let mut map = pending.blocking_lock();
6005 if let Some(tx) = map.remove(&id) {
6006 let _ = tx.send("\"probe_ok\"".to_string());
6007 }
6008 });
6009 }
6010 Ok(())
6013 }
6014 fn get_window_states(&self, _l: Option<&str>) -> Vec<WindowState> {
6015 Vec::new()
6016 }
6017 fn list_window_labels(&self) -> Vec<String> {
6018 Vec::new()
6019 }
6020 fn get_native_handle(&self, _l: Option<&str>) -> Result<isize, String> {
6021 Err("no handle".to_string())
6022 }
6023 fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
6024 Err("no window".to_string())
6025 }
6026 fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
6027 Ok(())
6028 }
6029 fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
6030 Ok(())
6031 }
6032 fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
6033 Ok(())
6034 }
6035 }
6036
6037 fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
6038 Arc::new(VictauriState {
6039 event_log: EventLog::new(1000),
6040 registry: CommandRegistry::new(),
6041 port: std::sync::atomic::AtomicU16::new(0),
6042 pending_evals: Arc::new(Mutex::new(HashMap::new())),
6043 recorder: EventRecorder::new(1000),
6044 privacy,
6045 eval_timeout: std::time::Duration::from_millis(100),
6046 shutdown_tx: tokio::sync::watch::channel(false).0,
6047 started_at: std::time::Instant::now(),
6048 tool_invocations: std::sync::atomic::AtomicU64::new(0),
6049 allow_file_navigation: false,
6050 command_timings: crate::introspection::CommandTimings::new(),
6051 fault_registry: crate::introspection::FaultRegistry::new(),
6052 contract_store: crate::introspection::ContractStore::new(),
6053 startup_timeline: crate::introspection::StartupTimeline::new(),
6054 event_bus: crate::introspection::EventBusMonitor::default(),
6055 task_tracker: crate::introspection::TaskTracker::new(),
6056 bridge_ready: std::sync::atomic::AtomicBool::new(true),
6057 bridge_notify: tokio::sync::Notify::new(),
6058 db_search_paths: Vec::new(),
6059 screencast: Arc::new(crate::screencast::Screencast::default()),
6060 probes: crate::introspection::AppStateProbes::default(),
6061 })
6062 }
6063
6064 fn blocking(cmds: &[&str]) -> PrivacyConfig {
6068 PrivacyConfig {
6069 command_blocklist: cmds.iter().map(|s| (*s).to_string()).collect(),
6070 ..Default::default()
6071 }
6072 }
6073
6074 fn ipc_event(command: &str) -> AppEvent {
6075 AppEvent::Ipc(IpcCall {
6076 id: format!("c-{command}"),
6077 command: command.to_string(),
6078 timestamp: chrono::Utc::now(),
6079 duration_ms: Some(1),
6080 result: IpcResult::Ok(json!(true)),
6081 arg_size_bytes: 0,
6082 webview_label: "main".to_string(),
6083 })
6084 }
6085
6086 fn result_text(r: &CallToolResult) -> String {
6087 r.content
6088 .iter()
6089 .filter_map(|c| match &c.raw {
6090 RawContent::Text(t) => Some(t.text.clone()),
6091 _ => None,
6092 })
6093 .collect::<Vec<_>>()
6094 .join("\n")
6095 }
6096
6097 async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
6098 match h.execute_tool(tool, args).await {
6099 Ok(r) => r,
6100 Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
6101 }
6102 }
6103
6104 #[tokio::test]
6106 async fn event_bus_caps_output_to_limit() {
6107 use crate::introspection::CapturedTauriEvent;
6110 let state = state_with(PrivacyConfig::default());
6111 for i in 0..150 {
6112 state.event_bus.push(CapturedTauriEvent {
6113 name: format!("evt-{i}"),
6114 payload: "{}".to_string(),
6115 timestamp: chrono::Utc::now().to_rfc3339(),
6116 });
6117 }
6118 let h = VictauriMcpHandler::new(state, Arc::new(RecordingBridge::default()));
6119
6120 let r = call(&h, "introspect", json!({"action": "event_bus"})).await;
6122 let v: serde_json::Value = serde_json::from_str(&result_text(&r)).unwrap();
6123 assert_eq!(
6124 v["tauri_events"]["count"], 150,
6125 "true total must be reported"
6126 );
6127 assert_eq!(v["tauri_events"]["returned"], 100, "default cap is 100");
6128 assert_eq!(v["tauri_events"]["truncated"], true);
6129 assert_eq!(v["tauri_events"]["events"].as_array().unwrap().len(), 100);
6130
6131 let r = call(
6133 &h,
6134 "introspect",
6135 json!({"action": "event_bus", "args": {"limit": 10}}),
6136 )
6137 .await;
6138 let v: serde_json::Value = serde_json::from_str(&result_text(&r)).unwrap();
6139 assert_eq!(v["tauri_events"]["returned"], 10);
6140 assert_eq!(v["tauri_events"]["events"].as_array().unwrap().len(), 10);
6141 }
6142
6143 #[tokio::test]
6146 async fn replay_never_invokes_a_blocklisted_command() {
6147 let bridge = RecordingBridge::default();
6148 let state = state_with(blocking(&["delete_account"]));
6149 state.recorder.start("s1".to_string()).unwrap();
6150 state.recorder.record_event(ipc_event("delete_account"));
6151 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6152
6153 let r = call(&h, "recording", json!({"action": "replay"})).await;
6154
6155 assert!(
6156 !bridge.invoked("delete_account"),
6157 "SIDE-EFFECT LEAK: replay handed a blocklisted command's invoke to the bridge (audit #30/#31)"
6158 );
6159 assert!(
6160 result_text(&r).contains("blocked"),
6161 "replay should report the command as blocked, got: {}",
6162 result_text(&r)
6163 );
6164 }
6165
6166 #[tokio::test]
6167 async fn replay_does_invoke_an_allowed_command() {
6168 let state = state_with(PrivacyConfig::default());
6171 let bridge = RecordingBridge::answering(state.pending_evals.clone());
6172 state.recorder.start("s1".to_string()).unwrap();
6173 state.recorder.record_event(ipc_event("greet"));
6174 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6175
6176 let _ = call(&h, "recording", json!({"action": "replay"})).await;
6177
6178 assert!(
6179 bridge.invoked("greet"),
6180 "positive control failed: an ALLOWED command was not invoked, so the negative test proves nothing"
6181 );
6182 }
6183
6184 #[tokio::test]
6185 async fn imported_session_cannot_invoke_a_blocklisted_command() {
6186 let bridge = RecordingBridge::default();
6189 let state = state_with(blocking(&["wipe_database"]));
6190 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6191
6192 let session = RecordedSession {
6193 id: "poisoned".to_string(),
6194 started_at: chrono::Utc::now(),
6195 events: vec![RecordedEvent {
6196 index: 0,
6197 timestamp: chrono::Utc::now(),
6198 event: ipc_event("wipe_database"),
6199 }],
6200 checkpoints: Vec::new(),
6201 };
6202 let session_json = serde_json::to_string(&session).unwrap();
6203
6204 let imp = call(
6205 &h,
6206 "recording",
6207 json!({"action": "import", "session_json": session_json}),
6208 )
6209 .await;
6210 assert_ne!(
6211 imp.is_error,
6212 Some(true),
6213 "import itself should succeed: {}",
6214 result_text(&imp)
6215 );
6216
6217 let r = call(&h, "recording", json!({"action": "replay"})).await;
6218 assert!(
6219 !bridge.invoked("wipe_database"),
6220 "SIDE-EFFECT LEAK: an imported session replayed a blocklisted command (audit #31)"
6221 );
6222 assert!(result_text(&r).contains("blocked"));
6223 }
6224
6225 #[tokio::test]
6228 async fn contract_record_never_invokes_a_blocklisted_command() {
6229 let bridge = RecordingBridge::default();
6230 let state = state_with(blocking(&["delete_account"]));
6231 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6232
6233 let r = call(
6234 &h,
6235 "introspect",
6236 json!({"action": "contract_record", "command": "delete_account", "args": {"confirm": true}}),
6237 )
6238 .await;
6239
6240 assert!(
6241 !bridge.invoked("delete_account"),
6242 "SIDE-EFFECT LEAK: contract_record invoked a blocklisted command (audit #30)"
6243 );
6244 assert_eq!(r.is_error, Some(true));
6245 assert!(
6246 result_text(&r).contains("blocked by privacy configuration"),
6247 "got: {}",
6248 result_text(&r)
6249 );
6250 }
6251
6252 #[tokio::test]
6253 async fn contract_record_does_invoke_an_allowed_command() {
6254 let state = state_with(PrivacyConfig::default());
6255 let bridge = RecordingBridge::answering(state.pending_evals.clone());
6256 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6257
6258 let _ = call(
6259 &h,
6260 "introspect",
6261 json!({"action": "contract_record", "command": "get_settings"}),
6262 )
6263 .await;
6264
6265 assert!(
6266 bridge.invoked("get_settings"),
6267 "positive control failed: contract_record did not invoke an allowed command"
6268 );
6269 }
6270
6271 #[tokio::test]
6273 async fn reserve_pending_is_a_hard_ceiling_under_concurrency() {
6274 let state = state_with(PrivacyConfig::default());
6280 {
6281 let mut p = state.pending_evals.lock().await;
6282 for i in 0..(MAX_PENDING_EVALS - 5) {
6283 let (tx, _rx) = tokio::sync::oneshot::channel();
6284 p.insert(format!("pre-{i}"), tx);
6285 }
6286 }
6287 let h = Arc::new(VictauriMcpHandler::new(
6288 state.clone(),
6289 Arc::new(RecordingBridge::default()),
6290 ));
6291 let mut tasks = Vec::new();
6292 for i in 0..50 {
6293 let h = h.clone();
6294 tasks.push(tokio::spawn(async move {
6295 let (tx, _rx) = tokio::sync::oneshot::channel();
6296 let ok = h.reserve_pending(&format!("c-{i}"), tx).await.is_ok();
6298 (ok, _rx)
6299 }));
6300 }
6301 let mut granted = 0;
6302 let mut keep = Vec::new();
6303 for t in tasks {
6304 let (ok, rx) = t.await.unwrap();
6305 if ok {
6306 granted += 1;
6307 }
6308 keep.push(rx); }
6310 let len = state.pending_evals.lock().await.len();
6311 assert!(
6312 len <= MAX_PENDING_EVALS,
6313 "ceiling breached: {len} > {MAX_PENDING_EVALS}"
6314 );
6315 assert_eq!(
6316 granted, 5,
6317 "exactly the 5 free slots should have been reserved, got {granted}"
6318 );
6319 drop(keep);
6320 }
6321
6322 #[tokio::test]
6323 async fn contract_check_never_reinvokes_a_now_blocklisted_command() {
6324 let bridge = RecordingBridge::default();
6327 let state = state_with(blocking(&["delete_account"]));
6328 state
6329 .contract_store
6330 .record(crate::introspection::ContractBaseline {
6331 command: "delete_account".to_string(),
6332 args: json!({}),
6333 shape: crate::introspection::JsonShape::from_value(&json!(true)),
6334 sample: "true".to_string(),
6335 recorded_at: chrono_now(),
6336 });
6337 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6338
6339 let _ = call(&h, "introspect", json!({"action": "contract_check"})).await;
6340
6341 assert!(
6342 !bridge.invoked("delete_account"),
6343 "SIDE-EFFECT LEAK: contract_check re-invoked a now-blocklisted command (audit #30)"
6344 );
6345 }
6346
6347 #[test]
6350 fn resource_reads_are_gated_by_their_mirrored_capability() {
6351 let cfg = PrivacyConfig {
6354 disabled_tools: HashSet::from([
6355 "logs.ipc".to_string(),
6356 "window.list".to_string(),
6357 "get_plugin_info".to_string(),
6358 ]),
6359 ..Default::default()
6360 };
6361 for uri in [
6362 RESOURCE_URI_IPC_LOG,
6363 RESOURCE_URI_WINDOWS,
6364 RESOURCE_URI_STATE,
6365 ] {
6366 let cap = resource_required_capability(uri).expect("resource maps to a capability");
6367 assert!(
6368 !cfg.is_tool_enabled(cap),
6369 "disabling capability {cap} must gate resource {uri} (audit B1)"
6370 );
6371 }
6372 let full = PrivacyConfig::default();
6374 for uri in [
6375 RESOURCE_URI_IPC_LOG,
6376 RESOURCE_URI_WINDOWS,
6377 RESOURCE_URI_STATE,
6378 ] {
6379 assert!(full.is_tool_enabled(resource_required_capability(uri).unwrap()));
6380 }
6381 }
6382
6383 #[tokio::test]
6386 async fn empty_auth_token_collapses_to_no_auth() {
6387 use http_body_util::BodyExt;
6388 use tower::ServiceExt;
6389
6390 for token in [Some(String::new()), Some(" ".to_string())] {
6391 let app = crate::mcp::server::build_app_full(
6392 state_with(PrivacyConfig::default()),
6393 Arc::new(RecordingBridge::default()),
6394 token.clone(),
6395 None,
6396 );
6397 let req = axum::extract::Request::builder()
6398 .uri("/info")
6399 .header("host", "127.0.0.1")
6400 .body(axum::body::Body::empty())
6401 .unwrap();
6402 let resp = app.oneshot(req).await.unwrap();
6403 assert_eq!(
6404 resp.status(),
6405 200,
6406 "/info must be reachable with empty token {token:?} (no auth layer)"
6407 );
6408 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
6409 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6410 assert_eq!(
6411 body["auth_required"],
6412 json!(false),
6413 "empty/whitespace token must report auth_required:false, not looks-protected-isnt (audit B2); token={token:?}"
6414 );
6415 }
6416 }
6417
6418 #[test]
6421 fn is_safe_env_key_drops_secrets_keeps_safe() {
6422 for secret in [
6423 "VICTAURI_AUTH_TOKEN",
6424 "TAURI_SIGNING_PRIVATE_KEY",
6425 "TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
6426 "CARGO_REGISTRY_TOKEN",
6427 "AWS_SECRET_ACCESS_KEY",
6428 "DATABASE_DSN",
6429 "GH_PAT",
6430 ] {
6431 assert!(
6432 !is_safe_env_key(secret),
6433 "{secret} is secret-shaped and must NOT be surfaced by app_info (audit #5)"
6434 );
6435 }
6436 for safe in [
6437 "HOME",
6438 "LANG",
6439 "TERM",
6440 "XDG_RUNTIME_DIR",
6441 "TAURI_ENV_PLATFORM",
6442 ] {
6443 assert!(
6444 is_safe_env_key(safe),
6445 "{safe} should be surfaced by app_info"
6446 );
6447 }
6448 }
6449}
6450
6451#[cfg(test)]
6458mod screenshot_visibility_tests {
6459 use super::*;
6460 use crate::bridge::WebviewBridge;
6461 use crate::privacy::PrivacyConfig;
6462 use std::collections::HashMap;
6463 use std::sync::Mutex as StdMutex;
6464 use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
6465
6466 fn window(label: &str, visible: bool) -> WindowState {
6467 WindowState {
6468 label: label.to_string(),
6469 title: label.to_string(),
6470 url: "http://localhost/".to_string(),
6471 visible,
6472 focused: false,
6473 maximized: false,
6474 minimized: false,
6475 fullscreen: false,
6476 position: (0, 0),
6477 size: (800, 600),
6478 }
6479 }
6480
6481 struct ConfigBridge {
6487 windows: Vec<WindowState>,
6488 handle_label: Arc<StdMutex<Option<Option<String>>>>,
6489 }
6490
6491 impl ConfigBridge {
6492 fn new(windows: Vec<WindowState>) -> Self {
6493 Self {
6494 windows,
6495 handle_label: Arc::new(StdMutex::new(None)),
6496 }
6497 }
6498 fn requested_handle(&self) -> Option<Option<String>> {
6502 self.handle_label
6503 .lock()
6504 .unwrap_or_else(std::sync::PoisonError::into_inner)
6505 .clone()
6506 }
6507 }
6508
6509 impl WebviewBridge for ConfigBridge {
6510 fn eval_webview(&self, _l: Option<&str>, _s: &str) -> Result<(), String> {
6511 Err("no eval".to_string())
6512 }
6513 fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
6514 match label {
6515 Some(l) => self
6516 .windows
6517 .iter()
6518 .filter(|w| w.label == l)
6519 .cloned()
6520 .collect(),
6521 None => self.windows.clone(),
6522 }
6523 }
6524 fn list_window_labels(&self) -> Vec<String> {
6525 self.windows.iter().map(|w| w.label.clone()).collect()
6526 }
6527 fn get_native_handle(&self, l: Option<&str>) -> Result<isize, String> {
6528 *self
6529 .handle_label
6530 .lock()
6531 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(l.map(str::to_string));
6532 Err("native handle path reached".to_string())
6533 }
6534 fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
6535 Ok(String::new())
6536 }
6537 fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
6538 Ok(())
6539 }
6540 fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
6541 Ok(())
6542 }
6543 fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
6544 Ok(())
6545 }
6546 }
6547
6548 fn handler_with(bridge: Arc<ConfigBridge>) -> VictauriMcpHandler {
6549 let state = Arc::new(VictauriState {
6550 event_log: EventLog::new(100),
6551 registry: CommandRegistry::new(),
6552 port: std::sync::atomic::AtomicU16::new(0),
6553 pending_evals: Arc::new(Mutex::new(HashMap::new())),
6554 recorder: EventRecorder::new(100),
6555 privacy: PrivacyConfig::default(),
6556 eval_timeout: std::time::Duration::from_millis(100),
6557 shutdown_tx: tokio::sync::watch::channel(false).0,
6558 started_at: std::time::Instant::now(),
6559 tool_invocations: std::sync::atomic::AtomicU64::new(0),
6560 allow_file_navigation: false,
6561 command_timings: crate::introspection::CommandTimings::new(),
6562 fault_registry: crate::introspection::FaultRegistry::new(),
6563 contract_store: crate::introspection::ContractStore::new(),
6564 startup_timeline: crate::introspection::StartupTimeline::new(),
6565 event_bus: crate::introspection::EventBusMonitor::default(),
6566 task_tracker: crate::introspection::TaskTracker::new(),
6567 bridge_ready: std::sync::atomic::AtomicBool::new(true),
6568 bridge_notify: tokio::sync::Notify::new(),
6569 db_search_paths: Vec::new(),
6570 screencast: Arc::new(crate::screencast::Screencast::default()),
6571 probes: crate::introspection::AppStateProbes::default(),
6572 });
6573 VictauriMcpHandler::new(state, bridge)
6574 }
6575
6576 fn error_text(r: &CallToolResult) -> String {
6577 r.content
6578 .iter()
6579 .filter_map(|c| match &c.raw {
6580 RawContent::Text(t) => Some(t.text.clone()),
6581 _ => None,
6582 })
6583 .collect::<Vec<_>>()
6584 .join("\n")
6585 }
6586
6587 #[tokio::test]
6588 async fn hidden_window_screenshot_errors_clearly() {
6589 let bridge = Arc::new(ConfigBridge::new(vec![
6590 window("main", true),
6591 window("briefing", false),
6592 ]));
6593 let h = handler_with(bridge.clone());
6594 let r = h
6595 .screenshot(Parameters(ScreenshotParams {
6596 window_label: Some("briefing".to_string()),
6597 }))
6598 .await;
6599 assert_eq!(r.is_error, Some(true), "hidden window must error");
6600 let text = error_text(&r);
6601 assert!(
6602 text.contains("not visible"),
6603 "error must explain the window is not visible, got: {text}"
6604 );
6605 assert!(
6606 bridge.requested_handle().is_none(),
6607 "must short-circuit BEFORE the OS-handle/capture path"
6608 );
6609 }
6610
6611 #[tokio::test]
6612 async fn visible_window_screenshot_proceeds_to_capture() {
6613 let bridge = Arc::new(ConfigBridge::new(vec![
6614 window("main", true),
6615 window("briefing", false),
6616 ]));
6617 let h = handler_with(bridge.clone());
6618 let r = h
6619 .screenshot(Parameters(ScreenshotParams {
6620 window_label: Some("main".to_string()),
6621 }))
6622 .await;
6623 let text = error_text(&r);
6626 assert!(
6627 text.contains("native handle path reached")
6628 || text.contains("cannot get window handle"),
6629 "a visible window must reach the capture path, got: {text}"
6630 );
6631 assert_eq!(
6632 bridge.requested_handle(),
6633 Some(Some("main".to_string())),
6634 "must capture the explicitly requested visible window"
6635 );
6636 }
6637
6638 #[tokio::test]
6643 async fn omitted_label_skips_hidden_main_for_visible_secondary() {
6644 let bridge = Arc::new(ConfigBridge::new(vec![
6645 window("main", false), window("secondary", true), ]));
6648 let h = handler_with(bridge.clone());
6649 let r = h
6650 .screenshot(Parameters(ScreenshotParams { window_label: None }))
6651 .await;
6652 let text = error_text(&r);
6653 assert!(
6654 !text.contains("not visible") && !text.contains("no visible window"),
6655 "a visible secondary window exists — must NOT error, got: {text}"
6656 );
6657 assert_eq!(
6658 bridge.requested_handle(),
6659 Some(Some("secondary".to_string())),
6660 "omitted label must resolve to the VISIBLE secondary, never hidden main"
6661 );
6662 }
6663
6664 #[tokio::test]
6666 async fn omitted_label_prefers_visible_main() {
6667 let bridge = Arc::new(ConfigBridge::new(vec![
6668 window("main", true),
6669 window("secondary", true),
6670 ]));
6671 let h = handler_with(bridge.clone());
6672 let _ = h
6673 .screenshot(Parameters(ScreenshotParams { window_label: None }))
6674 .await;
6675 assert_eq!(
6676 bridge.requested_handle(),
6677 Some(Some("main".to_string())),
6678 "with a visible main present, omitted label must resolve to main"
6679 );
6680 }
6681
6682 #[tokio::test]
6684 async fn all_hidden_omitted_label_errors() {
6685 let bridge = Arc::new(ConfigBridge::new(vec![
6686 window("main", false),
6687 window("briefing", false),
6688 ]));
6689 let h = handler_with(bridge.clone());
6690 let r = h
6691 .screenshot(Parameters(ScreenshotParams { window_label: None }))
6692 .await;
6693 assert_eq!(r.is_error, Some(true), "all-hidden must error");
6694 assert!(
6695 error_text(&r).contains("no visible window"),
6696 "error must say there is no visible window, got: {}",
6697 error_text(&r)
6698 );
6699 assert!(
6700 bridge.requested_handle().is_none(),
6701 "must NOT reach the OS-handle path when every window is hidden"
6702 );
6703 }
6704
6705 #[tokio::test]
6708 async fn unknown_label_falls_through_to_handle_resolution() {
6709 let bridge = Arc::new(ConfigBridge::new(vec![window("main", true)]));
6710 let h = handler_with(bridge.clone());
6711 let r = h
6712 .screenshot(Parameters(ScreenshotParams {
6713 window_label: Some("ghost".to_string()),
6714 }))
6715 .await;
6716 assert!(
6717 !error_text(&r).contains("not visible"),
6718 "unknown label must not be reported as 'not visible'"
6719 );
6720 assert_eq!(
6721 bridge.requested_handle(),
6722 Some(Some("ghost".to_string())),
6723 "unknown label must be forwarded verbatim to get_native_handle"
6724 );
6725 }
6726}