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