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