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, ghost_ipc_projection_js, js_string, json_result, json_truthy, missing_param,
40 sanitize_css_color, sanitize_injected_css, tool_disabled, tool_error, tool_error_with_hint,
41 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 ghost commands. Returns TWO separate lists: `frontend_only` = the true ghosts (commands invoked from the frontend that have NO backend handler — likely typos/bugs), and `registry_only` = informational (commands registered in the backend but never invoked from the frontend this session). Reads the JS-side IPC interception log, which ACCUMULATES every command seen this session (including earlier test/probe traffic) — so a `frontend_only` result reflects what was invoked at runtime, not necessarily a real frontend bug. For a clean signal, either (a) pass `since_ms` (e.g. 5000) to scope to commands invoked in the last N ms — non-destructive, the preferred per-test pattern: invoke the suspect action, then call this with `since_ms`; or (b) call `logs {action:'clear'}` first, then exercise the app, then run this.",
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(&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 if let Some(threshold) = params.slow_threshold_ms {
2823 stats.retain(|s| s.avg_ms >= threshold);
2824 }
2825 let result = serde_json::json!({
2826 "commands": stats,
2827 "total_commands_profiled": self.state.command_timings.all_stats().len(),
2828 "slow_threshold_ms": params.slow_threshold_ms,
2829 });
2830 json_result(&result)
2831 }
2832 IntrospectAction::Coverage => {
2833 let registered: Vec<String> = self
2834 .state
2835 .registry
2836 .list()
2837 .iter()
2838 .map(|c| c.name.clone())
2839 .collect();
2840
2841 let code = "return window.__VICTAURI__?.getIpcLog()";
2842 let invoked: std::collections::HashSet<String> = match self
2843 .eval_with_return(code, params.webview_label.as_deref())
2844 .await
2845 {
2846 Ok(json_str) => {
2847 if let Ok(entries) =
2848 serde_json::from_str::<Vec<serde_json::Value>>(&json_str)
2849 {
2850 entries
2851 .iter()
2852 .filter_map(|e| e.get("command").and_then(|c| c.as_str()))
2853 .map(String::from)
2854 .collect()
2855 } else {
2856 std::collections::HashSet::new()
2857 }
2858 }
2859 Err(_) => std::collections::HashSet::new(),
2860 };
2861
2862 let uncovered: Vec<&String> = registered
2863 .iter()
2864 .filter(|cmd| !invoked.contains(cmd.as_str()))
2865 .collect();
2866
2867 let coverage_pct = if registered.is_empty() {
2868 100.0
2869 } else {
2870 let covered = registered.len() - uncovered.len();
2871 (covered as f64 / registered.len() as f64) * 100.0
2872 };
2873
2874 let result = serde_json::json!({
2875 "registered_commands": registered.len(),
2876 "invoked_commands": invoked.len(),
2877 "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
2878 "uncovered": uncovered,
2879 "invoked_not_registered": invoked.iter()
2880 .filter(|cmd| !registered.contains(cmd))
2881 .collect::<Vec<_>>(),
2882 });
2883 json_result(&result)
2884 }
2885 IntrospectAction::ContractRecord => {
2886 let Some(command) = params.command else {
2887 return missing_param("command", "contract_record");
2888 };
2889 if !self.state.privacy.is_invoke_allowed(&command)
2892 || !self.state.privacy.is_command_allowed(&command)
2893 {
2894 return tool_error(format!(
2895 "command '{command}' is blocked by privacy configuration"
2896 ));
2897 }
2898 let args_json = params.args.unwrap_or(serde_json::json!({}));
2899 let args_str =
2900 serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
2901 let code = format!(
2902 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2903 js_string(&command)
2904 );
2905 match self
2906 .eval_with_return(&code, params.webview_label.as_deref())
2907 .await
2908 {
2909 Ok(result_str) => {
2910 let value: serde_json::Value = serde_json::from_str(&result_str)
2911 .unwrap_or(serde_json::Value::String(result_str.clone()));
2912 let shape = crate::introspection::JsonShape::from_value(&value);
2913 let sample = if result_str.len() > 4096 {
2914 format!("{}...(truncated)", &result_str[..4096])
2915 } else {
2916 result_str
2917 };
2918 let baseline = crate::introspection::ContractBaseline {
2919 command: command.clone(),
2920 args: args_json,
2921 shape: shape.clone(),
2922 sample,
2923 recorded_at: chrono_now(),
2924 };
2925 self.state.contract_store.record(baseline);
2926 let result = serde_json::json!({
2927 "recorded": true,
2928 "command": command,
2929 "shape_type": shape.type_name(),
2930 });
2931 json_result(&result)
2932 }
2933 Err(e) => tool_error(format!(
2934 "failed to invoke '{command}' for contract recording: {e}"
2935 )),
2936 }
2937 }
2938 IntrospectAction::ContractCheck => {
2939 let baselines = self.state.contract_store.all();
2940 if baselines.is_empty() {
2941 return json_result(&serde_json::json!({
2942 "checked": 0,
2943 "message": "no contract baselines recorded — use contract_record first",
2944 }));
2945 }
2946 let mut results = Vec::new();
2947 for baseline in &baselines {
2948 if !self.state.privacy.is_invoke_allowed(&baseline.command)
2951 || !self.state.privacy.is_command_allowed(&baseline.command)
2952 {
2953 continue;
2954 }
2955 let args_str =
2956 serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
2957 let code = format!(
2958 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2959 js_string(&baseline.command)
2960 );
2961 match self
2962 .eval_with_return(&code, params.webview_label.as_deref())
2963 .await
2964 {
2965 Ok(result_str) => {
2966 let value: serde_json::Value = serde_json::from_str(&result_str)
2967 .unwrap_or(serde_json::Value::String(result_str));
2968 let current_shape = crate::introspection::JsonShape::from_value(&value);
2969 let drift = crate::introspection::diff_shapes(
2970 &baseline.shape,
2971 ¤t_shape,
2972 &baseline.command,
2973 );
2974 results.push(drift);
2975 }
2976 Err(e) => {
2977 results.push(crate::introspection::ContractDrift {
2978 command: baseline.command.clone(),
2979 new_fields: Vec::new(),
2980 removed_fields: Vec::new(),
2981 type_changes: Vec::new(),
2982 shape_matches: false,
2983 });
2984 tracing::warn!(
2985 command = %baseline.command,
2986 error = %e,
2987 "contract check invocation failed"
2988 );
2989 }
2990 }
2991 }
2992 let passing = results.iter().filter(|r| r.shape_matches).count();
2993 let result = serde_json::json!({
2994 "checked": results.len(),
2995 "passing": passing,
2996 "failing": results.len() - passing,
2997 "contracts": results,
2998 });
2999 json_result(&result)
3000 }
3001 IntrospectAction::ContractList => {
3002 let baselines = self.state.contract_store.all();
3003 let result = serde_json::json!({
3004 "count": baselines.len(),
3005 "baselines": baselines.iter().map(|b| serde_json::json!({
3006 "command": b.command,
3007 "shape_type": b.shape.type_name(),
3008 "recorded_at": b.recorded_at,
3009 })).collect::<Vec<_>>(),
3010 });
3011 json_result(&result)
3012 }
3013 IntrospectAction::ContractClear => {
3014 let cleared = self.state.contract_store.clear();
3015 json_result(&serde_json::json!({
3016 "cleared": cleared,
3017 }))
3018 }
3019 IntrospectAction::StartupTiming => {
3020 let phases = self.state.startup_timeline.report();
3021 let result = serde_json::json!({
3022 "phases": phases,
3023 "total_ms": self.state.startup_timeline.total_ms(),
3024 "uptime_secs": self.state.started_at.elapsed().as_secs(),
3025 });
3026 json_result(&result)
3027 }
3028 IntrospectAction::Capabilities => {
3029 let config = self.bridge.tauri_config();
3030 let live_windows = self.bridge.list_window_labels();
3031
3032 let result = serde_json::json!({
3033 "app": {
3034 "identifier": config.get("identifier"),
3035 "product_name": config.get("product_name"),
3036 "version": config.get("version"),
3037 },
3038 "security": config.get("security"),
3039 "configured_windows": config.get("windows"),
3040 "live_windows": live_windows,
3041 "configured_plugins": config.get("plugins"),
3042 "victauri": {
3043 "registered_commands": self.state.registry.list().len(),
3044 "redaction_enabled": self.state.privacy.redaction_enabled,
3045 "privacy_profile": format!("{:?}", self.state.privacy.profile),
3046 "disabled_tools": &self.state.privacy.disabled_tools,
3047 },
3048 });
3049 json_result(&result)
3050 }
3051 #[allow(unused_variables)]
3052 IntrospectAction::DbHealth => {
3053 #[cfg(feature = "sqlite")]
3054 {
3055 let db_path = params.db_path.clone();
3056 match self.run_db_health(db_path.as_deref()).await {
3057 Ok(health) => json_result(&health),
3058 Err(e) => tool_error(format!("db_health failed: {e}")),
3059 }
3060 }
3061 #[cfg(not(feature = "sqlite"))]
3062 {
3063 tool_error("SQLite support not compiled in — enable the `sqlite` feature")
3064 }
3065 }
3066 IntrospectAction::PluginState => {
3067 let recording_active = self.state.recorder.is_recording();
3068 let recording_events = self.state.recorder.event_count();
3069 let result = serde_json::json!({
3070 "event_log": {
3071 "size": self.state.event_log.len(),
3072 "capacity": self.state.event_log.capacity(),
3073 },
3074 "registry": {
3075 "commands_registered": self.state.registry.list().len(),
3076 },
3077 "recording": {
3078 "active": recording_active,
3079 "events_captured": recording_events,
3080 },
3081 "faults": {
3082 "active_rules": self.state.fault_registry.list().len(),
3083 },
3084 "contracts": {
3085 "baselines_recorded": self.state.contract_store.all().len(),
3086 },
3087 "timings": {
3088 "commands_profiled": self.state.command_timings.all_stats().len(),
3089 },
3090 "event_bus": {
3091 "captured_events": self.state.event_bus.len(),
3092 },
3093 "tasks": {
3094 "total": self.state.task_tracker.list().len(),
3095 "active": self.state.task_tracker.active_count(),
3096 },
3097 "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
3098 "uptime_secs": self.state.started_at.elapsed().as_secs(),
3099 "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
3100 });
3101 json_result(&result)
3102 }
3103 IntrospectAction::Processes => {
3104 let pid = std::process::id();
3105 let uptime = self.state.started_at.elapsed();
3106 let children = crate::introspection::enumerate_child_processes();
3107 let host_memory = crate::memory::current_stats();
3108
3109 let result = serde_json::json!({
3110 "host": {
3111 "pid": pid,
3112 "uptime_secs": uptime.as_secs(),
3113 "platform": std::env::consts::OS,
3114 "arch": std::env::consts::ARCH,
3115 "memory": host_memory,
3116 },
3117 "children": children.iter().map(|c| serde_json::json!({
3118 "pid": c.pid,
3119 "name": c.name,
3120 "memory_bytes": c.memory_bytes,
3121 })).collect::<Vec<_>>(),
3122 "child_count": children.len(),
3123 "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
3124 });
3125 json_result(&result)
3126 }
3127 IntrospectAction::PluginTasks => {
3128 let tasks = self.state.task_tracker.list();
3129 let active = self.state.task_tracker.active_count();
3130 let result = serde_json::json!({
3131 "total": tasks.len(),
3132 "active": active,
3133 "finished": tasks.len() - active,
3134 "tasks": tasks,
3135 });
3136 json_result(&result)
3137 }
3138 IntrospectAction::EventBus => {
3139 let tauri_events = self.state.event_bus.events();
3140 let app_events = self.state.event_log.snapshot();
3141 let result = serde_json::json!({
3142 "tauri_events": {
3143 "count": tauri_events.len(),
3144 "events": tauri_events,
3145 },
3146 "app_events": {
3147 "count": app_events.len(),
3148 "capacity": self.state.event_log.capacity(),
3149 "events": app_events,
3150 },
3151 });
3152 json_result(&result)
3153 }
3154 IntrospectAction::EventBusClear => {
3155 let tauri_cleared = self.state.event_bus.clear();
3156 self.state.event_log.clear();
3157 json_result(&serde_json::json!({
3158 "tauri_events_cleared": tauri_cleared,
3159 "app_events_cleared": true,
3160 }))
3161 }
3162 }
3163 }
3164
3165 #[tool(
3168 description = "Probe a backend command handler under failure by faulting it for chaos engineering. \
3169 Simulate slow commands, backend errors, dropped responses, and corrupted data. \
3170 SCOPE: faults apply ONLY to commands you run via this server's `invoke_command` tool — \
3171 they do NOT intercept the app's real user-driven IPC (window.__TAURI_INTERNALS__.invoke), \
3172 which runs below the layer Victauri can reach. Use this to test a handler's error path when \
3173 YOU drive it; it does not reproduce a failure a user clicking the UI would see.\n\n\
3174 Actions:\n\
3175 - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`.\n\
3176 - `list`: List all active fault injection rules.\n\
3177 - `clear`: Remove a specific fault rule (requires `command`).\n\
3178 - `clear_all`: Remove all fault rules.",
3179 annotations(
3180 read_only_hint = false,
3181 destructive_hint = true,
3182 idempotent_hint = false,
3183 open_world_hint = false
3184 )
3185 )]
3186 async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
3187 if !self.state.privacy.is_tool_enabled("fault") {
3188 return tool_disabled("fault");
3189 }
3190
3191 match params.action {
3192 FaultAction::Inject => {
3193 let Some(command) = params.command else {
3194 return missing_param("command", "inject");
3195 };
3196 let Some(fault_kind) = params.fault_type else {
3197 return missing_param("fault_type", "inject");
3198 };
3199 let fault_type = match fault_kind {
3200 FaultKind::Delay => {
3201 let delay_ms = params.delay_ms.unwrap_or(1000);
3202 crate::introspection::FaultType::Delay { delay_ms }
3203 }
3204 FaultKind::Error => {
3205 let message = params
3206 .error_message
3207 .unwrap_or_else(|| "injected fault".to_string());
3208 crate::introspection::FaultType::Error { message }
3209 }
3210 FaultKind::Drop => crate::introspection::FaultType::Drop,
3211 FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
3212 };
3213 let config = crate::introspection::FaultConfig {
3214 command: command.clone(),
3215 fault_type: fault_type.clone(),
3216 trigger_count: 0,
3217 max_triggers: params.max_triggers.unwrap_or(0),
3218 created_at: std::time::Instant::now(),
3219 };
3220 self.state.fault_registry.inject(config);
3221 let result = serde_json::json!({
3222 "injected": true,
3223 "command": command,
3224 "fault_type": fault_type,
3225 "max_triggers": params.max_triggers.unwrap_or(0),
3226 });
3227 json_result(&result)
3228 }
3229 FaultAction::List => {
3230 let faults = self.state.fault_registry.list();
3231 let result = serde_json::json!({
3232 "count": faults.len(),
3233 "faults": faults.iter().map(|f| serde_json::json!({
3234 "command": f.command,
3235 "fault_type": f.fault_type,
3236 "trigger_count": f.trigger_count,
3237 "max_triggers": f.max_triggers,
3238 })).collect::<Vec<_>>(),
3239 });
3240 json_result(&result)
3241 }
3242 FaultAction::Clear => {
3243 let Some(command) = params.command else {
3244 return missing_param("command", "clear");
3245 };
3246 let removed = self.state.fault_registry.clear(&command);
3247 json_result(&serde_json::json!({
3248 "removed": removed,
3249 "command": command,
3250 }))
3251 }
3252 FaultAction::ClearAll => {
3253 let removed = self.state.fault_registry.clear_all();
3254 json_result(&serde_json::json!({
3255 "removed": removed,
3256 }))
3257 }
3258 }
3259 }
3260
3261 #[tool(
3264 description = "Correlate recent activity across all layers into a coherent narrative. \
3265 CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
3266 + window events across the Rust backend and webview simultaneously.\n\n\
3267 Actions:\n\
3268 - `summary`: High-level activity summary for the last N seconds (default 30). \
3269 Counts IPC calls, DOM mutations, console entries, network requests, errors.\n\
3270 - `last_action`: Correlate the most recent burst of events into a causal timeline \
3271 (e.g. 'IPC call → DOM update → console.log').\n\
3272 - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
3273 annotations(
3274 read_only_hint = true,
3275 destructive_hint = false,
3276 idempotent_hint = true,
3277 open_world_hint = false
3278 )
3279 )]
3280 async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
3281 if !self.state.privacy.is_tool_enabled("explain") {
3282 return tool_disabled("explain");
3283 }
3284
3285 match params.action {
3286 ExplainAction::Summary => {
3287 let secs = params.seconds.unwrap_or(30);
3288 let since = chrono::Utc::now()
3289 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3290 let events = self.state.event_log.since(since);
3291
3292 let mut ipc_count = 0u64;
3293 let mut dom_mutations = 0u64;
3294 let mut state_changes = 0u64;
3295 let mut console_count = 0u64;
3296 let mut window_events = 0u64;
3297 let mut interactions = 0u64;
3298 let mut top_commands: HashMap<String, u64> = HashMap::new();
3299 let mut errors: Vec<String> = Vec::new();
3300
3301 for event in &events {
3302 match event {
3303 victauri_core::AppEvent::Ipc(call) => {
3304 ipc_count += 1;
3305 *top_commands.entry(call.command.clone()).or_insert(0) += 1;
3306 if let victauri_core::IpcResult::Err(e) = &call.result {
3307 errors.push(format!("IPC {}: {e}", call.command));
3308 }
3309 }
3310 victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
3311 dom_mutations += u64::from(*mutation_count)
3312 }
3313 victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
3314 victauri_core::AppEvent::Console { level, message, .. } => {
3315 console_count += 1;
3316 if level == "error" {
3317 errors.push(format!("console.error: {message}"));
3318 }
3319 }
3320 victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
3321 victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
3322 _ => {}
3323 }
3324 }
3325
3326 let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
3327 sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
3328 let top: Vec<_> = sorted_cmds.iter().take(5).collect();
3329
3330 let narrative = format!(
3331 "{ipc_count} IPC call{} in the last {secs}s{}. \
3332 {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
3333 {console_count} console message{}, {window_events} window event{}. {}.",
3334 if ipc_count == 1 { "" } else { "s" },
3335 if top.is_empty() {
3336 String::new()
3337 } else {
3338 format!(
3339 ", dominated by {}",
3340 top.iter()
3341 .map(|(cmd, n)| format!("{cmd} ({n}x)"))
3342 .collect::<Vec<_>>()
3343 .join(", ")
3344 )
3345 },
3346 if dom_mutations == 1 { "" } else { "s" },
3347 if interactions == 1 { "" } else { "s" },
3348 if console_count == 1 { "" } else { "s" },
3349 if window_events == 1 { "" } else { "s" },
3350 if errors.is_empty() {
3351 "No errors".to_string()
3352 } else {
3353 format!(
3354 "{} error{}",
3355 errors.len(),
3356 if errors.len() == 1 { "" } else { "s" }
3357 )
3358 },
3359 );
3360
3361 let result = serde_json::json!({
3362 "time_window_secs": secs,
3363 "total_events": events.len(),
3364 "ipc_calls": ipc_count,
3365 "dom_mutations": dom_mutations,
3366 "state_changes": state_changes,
3367 "console_messages": console_count,
3368 "window_events": window_events,
3369 "interactions": interactions,
3370 "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
3371 serde_json::json!({"command": cmd, "count": n})
3372 }).collect::<Vec<_>>(),
3373 "errors": errors,
3374 "narrative": narrative,
3375 });
3376 json_result(&result)
3377 }
3378 ExplainAction::LastAction => {
3379 let secs = params.seconds.unwrap_or(5);
3380 let since = chrono::Utc::now()
3381 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3382 let events = self.state.event_log.since(since);
3383
3384 let timeline: Vec<serde_json::Value> = events
3385 .iter()
3386 .filter(|e| !e.is_internal())
3387 .map(|event| match event {
3388 victauri_core::AppEvent::Ipc(call) => serde_json::json!({
3389 "time": call.timestamp.to_rfc3339_opts(
3390 chrono::SecondsFormat::Millis, true
3391 ),
3392 "type": "ipc",
3393 "detail": format!(
3394 "{} {} ({}ms)",
3395 call.command,
3396 call.result,
3397 call.duration_ms.unwrap_or(0)
3398 ),
3399 }),
3400 victauri_core::AppEvent::DomMutation {
3401 timestamp,
3402 mutation_count,
3403 webview_label,
3404 } => serde_json::json!({
3405 "time": timestamp.to_rfc3339_opts(
3406 chrono::SecondsFormat::Millis, true
3407 ),
3408 "type": "dom_mutation",
3409 "detail": format!(
3410 "{mutation_count} element{} updated in {webview_label}",
3411 if *mutation_count == 1 { "" } else { "s" }
3412 ),
3413 }),
3414 victauri_core::AppEvent::DomInteraction {
3415 timestamp,
3416 action,
3417 selector,
3418 ..
3419 } => serde_json::json!({
3420 "time": timestamp.to_rfc3339_opts(
3421 chrono::SecondsFormat::Millis, true
3422 ),
3423 "type": "interaction",
3424 "detail": format!("{action} on {selector}"),
3425 }),
3426 victauri_core::AppEvent::StateChange {
3427 timestamp,
3428 key,
3429 caused_by,
3430 } => serde_json::json!({
3431 "time": timestamp.to_rfc3339_opts(
3432 chrono::SecondsFormat::Millis, true
3433 ),
3434 "type": "state_change",
3435 "detail": format!(
3436 "{key} changed{}",
3437 caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
3438 ),
3439 }),
3440 victauri_core::AppEvent::Console {
3441 timestamp,
3442 level,
3443 message,
3444 } => serde_json::json!({
3445 "time": timestamp.to_rfc3339_opts(
3446 chrono::SecondsFormat::Millis, true
3447 ),
3448 "type": "console",
3449 "detail": format!("console.{level}: {message}"),
3450 }),
3451 victauri_core::AppEvent::WindowEvent {
3452 timestamp,
3453 label,
3454 event,
3455 } => serde_json::json!({
3456 "time": timestamp.to_rfc3339_opts(
3457 chrono::SecondsFormat::Millis, true
3458 ),
3459 "type": "window_event",
3460 "detail": format!("{event} on window '{label}'"),
3461 }),
3462 _ => serde_json::json!({
3463 "time": event.timestamp().to_rfc3339_opts(
3464 chrono::SecondsFormat::Millis, true
3465 ),
3466 "type": "other",
3467 "detail": "unknown event type",
3468 }),
3469 })
3470 .collect();
3471
3472 let narrative = if timeline.is_empty() {
3473 format!("No activity in the last {secs}s.")
3474 } else {
3475 let parts: Vec<String> = timeline
3476 .iter()
3477 .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
3478 .map(String::from)
3479 .collect();
3480 parts.join(" → ")
3481 };
3482
3483 let result = serde_json::json!({
3484 "time_window_secs": secs,
3485 "event_count": timeline.len(),
3486 "timeline": timeline,
3487 "narrative": narrative,
3488 });
3489 json_result(&result)
3490 }
3491 ExplainAction::Diff => {
3492 let secs = params.seconds.unwrap_or(10);
3493 let since = chrono::Utc::now()
3494 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3495 let events = self.state.event_log.since(since);
3496
3497 let mut ipc_commands: Vec<String> = Vec::new();
3498 let mut dom_changes = 0u64;
3499 let mut error_count = 0u64;
3500 let mut interaction_count = 0u64;
3501 let mut console_messages = 0u64;
3502
3503 for event in &events {
3504 if event.is_internal() {
3505 continue;
3506 }
3507 match event {
3508 victauri_core::AppEvent::Ipc(call) => {
3509 ipc_commands.push(call.command.clone());
3510 if matches!(call.result, victauri_core::IpcResult::Err(_)) {
3511 error_count += 1;
3512 }
3513 }
3514 victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
3515 dom_changes += u64::from(*mutation_count)
3516 }
3517 victauri_core::AppEvent::DomInteraction { .. } => {
3518 interaction_count += 1;
3519 }
3520 victauri_core::AppEvent::Console { level, .. } => {
3521 console_messages += 1;
3522 if level == "error" {
3523 error_count += 1;
3524 }
3525 }
3526 _ => {}
3527 }
3528 }
3529
3530 ipc_commands.dedup();
3531
3532 let result = serde_json::json!({
3533 "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
3534 "time_window_secs": secs,
3535 "total_events": events.len(),
3536 "ipc_calls_made": ipc_commands.len(),
3537 "unique_commands": ipc_commands,
3538 "dom_elements_changed": dom_changes,
3539 "interactions": interaction_count,
3540 "console_messages": console_messages,
3541 "errors": error_count,
3542 });
3543 json_result(&result)
3544 }
3545 }
3546 }
3547}
3548
3549impl VictauriMcpHandler {
3550 pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
3552 Self {
3553 state,
3554 bridge,
3555 subscriptions: Arc::new(Mutex::new(HashSet::new())),
3556 bridge_checked: Arc::new(AtomicBool::new(false)),
3557 probed_labels: Arc::new(Mutex::new(HashSet::new())),
3558 timed_out_labels: Arc::new(Mutex::new(HashSet::new())),
3559 }
3560 }
3561
3562 pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
3563 self.state.privacy.is_tool_enabled(name)
3564 }
3565
3566 pub(crate) async fn execute_tool(
3567 &self,
3568 name: &str,
3569 args: serde_json::Value,
3570 ) -> Result<CallToolResult, rest::ToolCallError> {
3571 let capability = authz::canonical_capability(name, &args);
3575 if !self.state.privacy.is_call_allowed(name, &capability) {
3576 return Ok(tool_disabled(&capability));
3577 }
3578 self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
3579 let start = std::time::Instant::now();
3580 tracing::debug!(tool = %name, "REST tool invocation started");
3581
3582 let result = match name {
3583 "eval_js" => {
3584 let p: EvalJsParams = Self::parse_args(args)?;
3585 self.eval_js(Parameters(p)).await
3586 }
3587 "dom_snapshot" => {
3588 let p: SnapshotParams = Self::parse_args(args)?;
3589 self.dom_snapshot(Parameters(p)).await
3590 }
3591 "find_elements" => {
3592 let p: FindElementsParams = Self::parse_args(args)?;
3593 self.find_elements(Parameters(p)).await
3594 }
3595 "invoke_command" => {
3596 let p: InvokeCommandParams = Self::parse_args(args)?;
3597 self.invoke_command(Parameters(p)).await
3598 }
3599 "screenshot" => {
3600 let p: ScreenshotParams = Self::parse_args(args)?;
3601 self.screenshot(Parameters(p)).await
3602 }
3603 "verify_state" => {
3604 let p: VerifyStateParams = Self::parse_args(args)?;
3605 self.verify_state(Parameters(p)).await
3606 }
3607 "detect_ghost_commands" => {
3608 let p: GhostCommandParams = Self::parse_args(args)?;
3609 self.detect_ghost_commands(Parameters(p)).await
3610 }
3611 "check_ipc_integrity" => {
3612 let p: IpcIntegrityParams = Self::parse_args(args)?;
3613 self.check_ipc_integrity(Parameters(p)).await
3614 }
3615 "wait_for" => {
3616 let p: WaitForParams = Self::parse_args(args)?;
3617 self.wait_for(Parameters(p)).await
3618 }
3619 "assert_semantic" => {
3620 let p: SemanticAssertParams = Self::parse_args(args)?;
3621 self.assert_semantic(Parameters(p)).await
3622 }
3623 "resolve_command" => {
3624 let p: ResolveCommandParams = Self::parse_args(args)?;
3625 self.resolve_command(Parameters(p)).await
3626 }
3627 "get_registry" => {
3628 let p: RegistryParams = Self::parse_args(args)?;
3629 self.get_registry(Parameters(p)).await
3630 }
3631 "app_state" => {
3632 let p: AppStateParams = Self::parse_args(args)?;
3633 self.app_state(Parameters(p)).await
3634 }
3635 "get_memory_stats" => self.get_memory_stats().await,
3636 "get_plugin_info" => self.get_plugin_info().await,
3637 "get_diagnostics" => {
3638 let p: DiagnosticsParams = Self::parse_args(args)?;
3639 self.get_diagnostics(Parameters(p)).await
3640 }
3641 "app_info" => self.app_info().await,
3642 "list_app_dir" => {
3643 let p: ListAppDirParams = Self::parse_args(args)?;
3644 self.list_app_dir(Parameters(p)).await
3645 }
3646 "read_app_file" => {
3647 let p: ReadAppFileParams = Self::parse_args(args)?;
3648 self.read_app_file(Parameters(p)).await
3649 }
3650 "query_db" => {
3651 let p: QueryDbParams = Self::parse_args(args)?;
3652 self.query_db(Parameters(p)).await
3653 }
3654 "interact" => {
3655 let p: InteractParams = Self::parse_args(args)?;
3656 self.interact(Parameters(p)).await
3657 }
3658 "input" => {
3659 let p: InputParams = Self::parse_args(args)?;
3660 self.input(Parameters(p)).await
3661 }
3662 "window" => {
3663 let p: WindowParams = Self::parse_args(args)?;
3664 self.window(Parameters(p)).await
3665 }
3666 "storage" => {
3667 let p: StorageParams = Self::parse_args(args)?;
3668 self.storage(Parameters(p)).await
3669 }
3670 "navigate" => {
3671 let p: NavigateParams = Self::parse_args(args)?;
3672 self.navigate(Parameters(p)).await
3673 }
3674 "recording" => {
3675 let p: RecordingParams = Self::parse_args(args)?;
3676 self.recording(Parameters(p)).await
3677 }
3678 "inspect" => {
3679 let p: InspectParams = Self::parse_args(args)?;
3680 self.inspect(Parameters(p)).await
3681 }
3682 "css" => {
3683 let p: CssParams = Self::parse_args(args)?;
3684 self.css(Parameters(p)).await
3685 }
3686 "route" => {
3687 let p: RouteParams = Self::parse_args(args)?;
3688 self.route(Parameters(p)).await
3689 }
3690 "trace" => {
3691 let p: TraceParams = Self::parse_args(args)?;
3692 self.trace(Parameters(p)).await
3693 }
3694 "animation" => {
3695 let p: AnimationParams = Self::parse_args(args)?;
3696 self.animation(Parameters(p)).await
3697 }
3698 "logs" => {
3699 let p: LogsParams = Self::parse_args(args)?;
3700 self.logs(Parameters(p)).await
3701 }
3702 "introspect" => {
3703 let p: IntrospectParams = Self::parse_args(args)?;
3704 self.introspect(Parameters(p)).await
3705 }
3706 "fault" => {
3707 let p: FaultParams = Self::parse_args(args)?;
3708 self.fault(Parameters(p)).await
3709 }
3710 "explain" => {
3711 let p: ExplainParams = Self::parse_args(args)?;
3712 self.explain(Parameters(p)).await
3713 }
3714 _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
3715 };
3716
3717 let elapsed = start.elapsed();
3718 tracing::debug!(
3719 tool = %name,
3720 elapsed_ms = elapsed.as_millis() as u64,
3721 "REST tool invocation completed"
3722 );
3723
3724 if self.state.privacy.redaction_enabled {
3725 Ok(Self::redact_result(result, &self.state.privacy))
3726 } else {
3727 Ok(result)
3728 }
3729 }
3730
3731 fn parse_args<T: serde::de::DeserializeOwned>(
3732 args: serde_json::Value,
3733 ) -> Result<T, rest::ToolCallError> {
3734 serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
3735 }
3736
3737 fn redact_result(
3738 mut result: CallToolResult,
3739 privacy: &crate::privacy::PrivacyConfig,
3740 ) -> CallToolResult {
3741 for item in &mut result.content {
3742 if let RawContent::Text(ref mut tc) = item.raw {
3743 tc.text = privacy.redact_output(&tc.text);
3744 }
3745 }
3746 result
3747 }
3748
3749 fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
3750 match dir.unwrap_or(AppDir::Data) {
3751 AppDir::Data => self.bridge.app_data_dir(),
3752 AppDir::Config => self.bridge.app_config_dir(),
3753 AppDir::Log => self.bridge.app_log_dir(),
3754 AppDir::LocalData => self.bridge.app_local_data_dir(),
3755 }
3756 }
3757
3758 fn lexical_safe(sub: &std::path::Path) -> Result<(), String> {
3766 use std::path::Component;
3767 if sub.is_absolute() {
3768 return Err("path traversal not allowed: absolute paths are rejected".to_string());
3769 }
3770 for component in sub.components() {
3771 match component {
3772 Component::ParentDir => {
3773 return Err("path traversal not allowed: '..' is rejected".to_string());
3774 }
3775 Component::Prefix(_) | Component::RootDir => {
3776 return Err(
3777 "path traversal not allowed: absolute paths are rejected".to_string()
3778 );
3779 }
3780 Component::CurDir | Component::Normal(_) => {}
3781 }
3782 }
3783 Ok(())
3784 }
3785
3786 fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
3787 let canon_base = std::fs::canonicalize(base)
3788 .map_err(|e| format!("cannot resolve base directory: {e}"))?;
3789 let canon_target = std::fs::canonicalize(target)
3790 .map_err(|e| format!("cannot resolve target path: {e}"))?;
3791 if !canon_target.starts_with(&canon_base) {
3792 return Err("path traversal not allowed".to_string());
3793 }
3794 Ok(())
3795 }
3796
3797 fn list_dir_recursive(
3798 dir: &std::path::Path,
3799 base: &std::path::Path,
3800 depth: u32,
3801 max_depth: u32,
3802 pattern: Option<&str>,
3803 entries: &mut Vec<serde_json::Value>,
3804 ) {
3805 if entries.len() >= MAX_DIR_ENTRIES {
3806 return;
3807 }
3808 let Ok(read_dir) = std::fs::read_dir(dir) else {
3809 return;
3810 };
3811 for entry in read_dir.flatten() {
3812 if entries.len() >= MAX_DIR_ENTRIES {
3813 return;
3814 }
3815 let path = entry.path();
3816 if path.is_symlink() {
3817 continue;
3818 }
3819 let name = entry.file_name().to_string_lossy().into_owned();
3820 let relative = path
3821 .strip_prefix(base)
3822 .unwrap_or(&path)
3823 .to_string_lossy()
3824 .into_owned();
3825
3826 if let Some(pat) = pattern
3827 && !Self::matches_glob(&name, pat)
3828 && !path.is_dir()
3829 {
3830 continue;
3831 }
3832
3833 let is_dir = path.is_dir();
3834 let meta = std::fs::metadata(&path).ok();
3835
3836 entries.push(serde_json::json!({
3837 "name": name,
3838 "path": relative,
3839 "is_dir": is_dir,
3840 "size": meta.as_ref().map(std::fs::Metadata::len),
3841 "modified": meta.as_ref()
3842 .and_then(|m| m.modified().ok())
3843 .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
3844 .unwrap_or_default().as_secs()),
3845 }));
3846
3847 if is_dir && depth < max_depth {
3848 Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
3849 }
3850 }
3851 }
3852
3853 fn matches_glob(name: &str, pattern: &str) -> bool {
3854 if pattern == "*" {
3855 return true;
3856 }
3857 if let Some(suffix) = pattern.strip_prefix("*.") {
3858 return name.ends_with(&format!(".{suffix}"));
3859 }
3860 if let Some(prefix) = pattern.strip_suffix("*") {
3861 return name.starts_with(prefix);
3862 }
3863 name == pattern
3864 }
3865
3866 async fn window_introspectability(&self) -> CallToolResult {
3872 let labels = self.bridge.list_window_labels();
3873 let states = self.bridge.get_window_states(None);
3874 let mut report = Vec::with_capacity(labels.len());
3875 let mut blind = 0usize;
3876 for label in &labels {
3877 let visible = states.iter().find(|s| &s.label == label).map(|s| s.visible);
3878 let introspectable = self.probe_bridge(Some(label)).await.is_ok();
3879 if !introspectable {
3880 blind += 1;
3881 }
3882 let note = if introspectable {
3883 "ok — Victauri JS bridge is responding".to_string()
3884 } else if visible == Some(true) {
3885 format!(
3886 "NOT introspectable although the window is visible — almost certainly missing \
3887 the Victauri capability. Add \"victauri:default\" to the capability file \
3888 (src-tauri/capabilities/*.json) whose \"windows\" list includes \"{label}\", \
3889 then rebuild. Capabilities are baked at compile time, so a rebuild is required."
3890 )
3891 } else {
3892 "NOT introspectable (window is hidden and/or has no bridge) — show the window to \
3893 confirm, and ensure its capability includes \"victauri:default\", then rebuild."
3894 .to_string()
3895 };
3896 report.push(serde_json::json!({
3897 "label": label,
3898 "visible": visible,
3899 "introspectable": introspectable,
3900 "note": note,
3901 }));
3902 }
3903 let hint = if blind > 0 {
3904 "Windows with introspectable:false have no working Victauri JS bridge — eval_js, \
3905 dom_snapshot, animation, find_elements, etc. cannot see them. The usual cause is a \
3906 missing \"victauri:default\" capability for that window: Tauri's per-window permission \
3907 ACL silently blocks the bridge's callback IPC. This capability is required per window, \
3908 not just for the main window. (Note: probing a blind window takes ~2s each.)"
3909 } else {
3910 "All windows are introspectable."
3911 };
3912 json_result(&serde_json::json!({
3913 "windows": report,
3914 "introspectable_count": labels.len().saturating_sub(blind),
3915 "blind_count": blind,
3916 "hint": hint,
3917 }))
3918 }
3919
3920 async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
3921 match self.eval_with_return(code, webview_label).await {
3922 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
3923 Err(e) => tool_error(e),
3924 }
3925 }
3926
3927 async fn eval_with_return(
3928 &self,
3929 code: &str,
3930 webview_label: Option<&str>,
3931 ) -> Result<String, String> {
3932 self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
3933 .await
3934 }
3935
3936 async fn probe_bridge(&self, webview_label: Option<&str>) -> Result<(), String> {
3937 let id = uuid::Uuid::new_v4().to_string();
3938 let (tx, rx) = tokio::sync::oneshot::channel();
3939 {
3940 let mut pending = self.state.pending_evals.lock().await;
3941 pending.insert(id.clone(), tx);
3942 }
3943 let id_js = js_string(&id);
3944 let probe = format!(
3945 r#"(async()=>{{await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',{{id:{id_js},result:'"probe_ok"'}});}})();"#
3946 );
3947 if let Err(e) = self.bridge.eval_webview(webview_label, &probe) {
3948 self.state.pending_evals.lock().await.remove(&id);
3949 return Err(format!("eval injection failed: {e}"));
3950 }
3951 if let Ok(Ok(_)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await {
3952 Ok(())
3953 } else {
3954 self.state.pending_evals.lock().await.remove(&id);
3955 let label = webview_label.unwrap_or("default");
3956 Err(format!(
3957 "bridge not responding on window '{label}' — the window may be hidden, \
3958 missing the victauri capability, or the JS bridge is not loaded"
3959 ))
3960 }
3961 }
3962
3963 async fn eval_with_return_timeout(
3964 &self,
3965 code: &str,
3966 webview_label: Option<&str>,
3967 timeout: std::time::Duration,
3968 ) -> Result<String, String> {
3969 if !self
3974 .state
3975 .bridge_ready
3976 .load(std::sync::atomic::Ordering::Acquire)
3977 {
3978 let notified = self.state.bridge_notify.notified();
3979 if !self
3980 .state
3981 .bridge_ready
3982 .load(std::sync::atomic::Ordering::Acquire)
3983 {
3984 let _ = tokio::time::timeout(std::time::Duration::from_secs(5), notified).await;
3985 }
3986 }
3987
3988 let label_key =
3991 webview_label.map_or_else(|| "\u{1}__default__".to_string(), str::to_string);
3992
3993 if webview_label.is_some() {
3996 let already_probed = self.probed_labels.lock().await.contains(&label_key);
3997 if !already_probed {
3998 self.probe_bridge(webview_label).await?;
3999 self.probed_labels.lock().await.insert(label_key.clone());
4000 }
4001 }
4002
4003 if self.timed_out_labels.lock().await.remove(&label_key) {
4010 self.probe_bridge(webview_label).await.map_err(|e| {
4011 format!("{e} (previous eval on this window timed out; the webview may have reloaded or the app stopped responding)")
4012 })?;
4013 }
4014
4015 let id = uuid::Uuid::new_v4().to_string();
4016 let (tx, rx) = tokio::sync::oneshot::channel();
4017
4018 {
4019 let mut pending = self.state.pending_evals.lock().await;
4020 if pending.len() >= MAX_PENDING_EVALS {
4021 return Err(format!(
4022 "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
4023 ));
4024 }
4025 pending.insert(id.clone(), tx);
4026 }
4027
4028 let code = if should_prepend_return(code) {
4035 format!("return {}", code.trim())
4036 } else {
4037 code.trim().to_string()
4038 };
4039
4040 let id_js = js_string(&id);
4041
4042 let watchdog = format!(
4055 r"
4056 (function () {{
4057 window.__VIC_EVAL__ = window.__VIC_EVAL__ || {{}};
4058 var s = (window.__VIC_EVAL__[{id_js}] =
4059 window.__VIC_EVAL__[{id_js}] || {{ started: false, done: false }});
4060 setTimeout(function () {{
4061 if (s.started || s.done) return;
4062 s.done = true;
4063 try {{
4064 window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4065 id: {id_js},
4066 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)' }})
4067 }});
4068 }} catch (e) {{}}
4069 delete window.__VIC_EVAL__[{id_js}];
4070 }}, {PARSE_WATCHDOG_MS});
4071 }})();
4072 "
4073 );
4074
4075 let inject = format!(
4076 r"
4077 (async () => {{
4078 var __s = (window.__VIC_EVAL__ && window.__VIC_EVAL__[{id_js}]) || null;
4079 if (__s) __s.started = true;
4080 try {{
4081 const __result = await (async () => {{ {code} }})();
4082 if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }}
4083 const __type = __result === undefined ? 'undefined'
4084 : __result === null ? 'null' : 'value';
4085 const __val = __type === 'undefined' ? null
4086 : __type === 'null' ? null : __result;
4087 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4088 id: {id_js},
4089 result: JSON.stringify({{ __victauri_ok: __val, __victauri_type: __type }})
4090 }});
4091 }} catch (e) {{
4092 if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }}
4093 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4094 id: {id_js},
4095 result: JSON.stringify({{ __victauri_err: String(e && e.message || e) }})
4096 }});
4097 }}
4098 }})();
4099 "
4100 );
4101
4102 if let Err(e) = self.bridge.eval_webview(webview_label, &watchdog) {
4106 self.state.pending_evals.lock().await.remove(&id);
4107 return Err(format!("eval injection failed: {e}"));
4108 }
4109 if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
4110 self.state.pending_evals.lock().await.remove(&id);
4111 return Err(format!("eval injection failed: {e}"));
4112 }
4113
4114 match tokio::time::timeout(timeout, rx).await {
4115 Ok(Ok(raw)) => {
4116 self.check_bridge_version_once();
4117 if raw.len() > MAX_EVAL_RESULT_LEN {
4118 return Err(format!(
4119 "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
4120 raw.len()
4121 ));
4122 }
4123 unwrap_eval_envelope(raw)
4124 }
4125 Ok(Err(_)) => Err("eval callback channel closed".to_string()),
4126 Err(_) => {
4127 self.state.pending_evals.lock().await.remove(&id);
4128 self.timed_out_labels.lock().await.insert(label_key.clone());
4132 Err(format!(
4133 "eval timed out after {}s — the code began executing but never resolved. \
4134 (A syntax/parse error would have failed fast via the parse watchdog, so \
4135 this is NOT a parse error.) Common causes: an unresolved promise, an \
4136 infinite loop, an `await` on something that never settles, or the webview \
4137 reloaded / the app stopped responding mid-eval. If the app may have \
4138 navigated or crashed, retry (the next call fails fast if the bridge is \
4139 gone).",
4140 timeout.as_secs()
4141 ))
4142 }
4143 }
4144 }
4145
4146 #[cfg(feature = "sqlite")]
4147 async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
4148 let mut roots: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
4150 for d in [
4151 self.bridge.app_data_dir(),
4152 self.bridge.app_local_data_dir(),
4153 self.bridge.app_config_dir(),
4154 ]
4155 .into_iter()
4156 .flatten()
4157 {
4158 roots.push(d);
4159 }
4160
4161 let path = if let Some(p) = db_path {
4162 let candidate = std::path::Path::new(p);
4163 if candidate.is_absolute() {
4164 if !roots
4165 .iter()
4166 .any(|r| Self::safe_within(r, candidate).is_ok())
4167 {
4168 return Err(format!(
4169 "absolute path '{p}' is not within an allowed directory; \
4170 register its parent via VictauriBuilder::db_search_paths"
4171 ));
4172 }
4173 candidate.to_path_buf()
4174 } else {
4175 roots
4176 .iter()
4177 .map(|r| r.join(p))
4178 .find(|c| c.exists())
4179 .ok_or_else(|| format!("database not found: {p}"))?
4180 }
4181 } else {
4182 let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
4186 roots.clone()
4187 } else {
4188 self.state.db_search_paths.clone()
4189 };
4190 crate::database::select_app_database(&select_dirs)?
4191 };
4192 let path_str = path
4198 .to_str()
4199 .ok_or_else(|| "invalid path encoding".to_string())?
4200 .to_string();
4201
4202 tokio::task::spawn_blocking(move || {
4203 let conn = rusqlite::Connection::open_with_flags(
4204 &path_str,
4205 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
4206 )
4207 .map_err(|e| format!("cannot open database: {e}"))?;
4208
4209 let journal_mode: String = conn
4210 .pragma_query_value(None, "journal_mode", |r| r.get(0))
4211 .unwrap_or_else(|_| "unknown".to_string());
4212
4213 let page_count: i64 = conn
4214 .pragma_query_value(None, "page_count", |r| r.get(0))
4215 .unwrap_or(0);
4216
4217 let page_size: i64 = conn
4218 .pragma_query_value(None, "page_size", |r| r.get(0))
4219 .unwrap_or(0);
4220
4221 let freelist_count: i64 = conn
4222 .pragma_query_value(None, "freelist_count", |r| r.get(0))
4223 .unwrap_or(0);
4224
4225 let wal_checkpoint: String = if journal_mode == "wal" {
4226 let mut info = String::from("n/a");
4227 let _ = conn.pragma_query(None, "wal_checkpoint", |r| {
4228 let busy: i64 = r.get(0)?;
4229 let checkpointed: i64 = r.get(1)?;
4230 let total: i64 = r.get(2)?;
4231 info = format!("busy={busy}, checkpointed={checkpointed}, total={total}");
4232 Ok(())
4233 });
4234 info
4235 } else {
4236 "n/a (not WAL mode)".to_string()
4237 };
4238
4239 let integrity: String = conn
4240 .pragma_query_value(None, "quick_check", |r| r.get(0))
4241 .unwrap_or_else(|_| "failed".to_string());
4242
4243 let db_size_bytes = page_count * page_size;
4244 let db_size_mb = db_size_bytes as f64 / (1024.0 * 1024.0);
4245
4246 let mut tables = Vec::new();
4247 if let Ok(mut stmt) =
4248 conn.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
4249 && let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0))
4250 {
4251 for name in rows.flatten() {
4252 let count: i64 = conn
4253 .query_row(&format!("SELECT count(*) FROM [{name}]"), [], |r| r.get(0))
4254 .unwrap_or(0);
4255 tables.push(serde_json::json!({
4256 "name": name,
4257 "row_count": count,
4258 }));
4259 }
4260 }
4261
4262 Ok(serde_json::json!({
4263 "database": path_str,
4264 "journal_mode": journal_mode,
4265 "page_count": page_count,
4266 "page_size": page_size,
4267 "db_size_mb": (db_size_mb * 100.0).round() / 100.0,
4268 "freelist_count": freelist_count,
4269 "wal_checkpoint": wal_checkpoint,
4270 "integrity_check": integrity,
4271 "tables": tables,
4272 }))
4273 })
4274 .await
4275 .map_err(|e| format!("db health task failed: {e}"))?
4276 }
4277
4278 fn check_bridge_version_once(&self) {
4279 if self.bridge_checked.swap(true, Ordering::Relaxed) {
4280 return;
4281 }
4282 let handler = self.clone();
4283 tokio::spawn(async move {
4284 match handler
4285 .eval_with_return_timeout(
4286 "window.__VICTAURI__?.version",
4287 None,
4288 std::time::Duration::from_secs(5),
4289 )
4290 .await
4291 {
4292 Ok(v) => {
4293 let v = v.trim_matches('"');
4294 if v == BRIDGE_VERSION {
4295 tracing::debug!("Bridge version verified: {v}");
4296 } else {
4297 tracing::warn!(
4298 "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
4299 );
4300 }
4301 }
4302 Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
4303 }
4304 });
4305 }
4306}
4307
4308const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
4309It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
4310(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
4311(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
4312\n\nBACKEND tools (direct Rust access, no webview needed): \
4313'app_info' (app config, directory paths, discovered databases, process info), \
4314'list_app_dir' (browse app data/config/log directories), \
4315'read_app_file' (read files from app directories), \
4316'query_db' (read-only SQLite queries with auto-discovery). \
4317\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
4318'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
4319capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
4320Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
4321capability/security auditing, database diagnostics, plugin state, child process enumeration, \
4322task tracking, and automatic Tauri event bus monitoring. \
4323'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
4324drops, and response corruption into Tauri commands at the Rust layer. \
4325'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
4326activity across IPC + DOM + console + network + window events into a coherent narrative. \
4327\n\nWEBVIEW tools: \
4328'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
4329'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
4330'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
4331\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
4332\n\nCOMPOUND tools with an 'action' parameter: \
4333'window' (get_state, list, manage, resize, move_to, set_title), \
4334'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
4335set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
4336get_events, events_between, get_replay, export, import, replay), \
4337'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
4338\n\nOTHER: verify_state, wait_for (incl. 'expression'/'event' conditions to await \
4339async backend work to true completion), assert_semantic, resolve_command, \
4340app_state (app-defined backend state probes), \
4341get_memory_stats, get_plugin_info, get_diagnostics.";
4342
4343impl ServerHandler for VictauriMcpHandler {
4344 fn get_info(&self) -> ServerInfo {
4345 ServerInfo::new(
4346 ServerCapabilities::builder()
4347 .enable_tools()
4348 .enable_resources()
4349 .enable_resources_subscribe()
4350 .build(),
4351 )
4352 .with_instructions(SERVER_INSTRUCTIONS)
4353 }
4354
4355 async fn list_tools(
4356 &self,
4357 _request: Option<PaginatedRequestParams>,
4358 _context: RequestContext<RoleServer>,
4359 ) -> Result<ListToolsResult, ErrorData> {
4360 let all_tools = Self::tool_router().list_all();
4361 let filtered: Vec<Tool> = all_tools
4362 .into_iter()
4363 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
4364 .collect();
4365 Ok(ListToolsResult {
4366 tools: filtered,
4367 ..Default::default()
4368 })
4369 }
4370
4371 async fn call_tool(
4372 &self,
4373 request: CallToolRequestParams,
4374 context: RequestContext<RoleServer>,
4375 ) -> Result<CallToolResult, ErrorData> {
4376 let tool_name: String = request.name.as_ref().to_owned();
4377 let args_value = serde_json::Value::Object(request.arguments.clone().unwrap_or_default());
4380 let capability = authz::canonical_capability(&tool_name, &args_value);
4381 if !self.state.privacy.is_call_allowed(&tool_name, &capability) {
4382 tracing::debug!(tool = %tool_name, capability = %capability, "tool call blocked by privacy config");
4383 return Ok(tool_disabled(&capability));
4384 }
4385 self.state
4386 .tool_invocations
4387 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4388 let start = std::time::Instant::now();
4389 tracing::debug!(tool = %tool_name, "tool invocation started");
4390 let ctx = ToolCallContext::new(self, request, context);
4391 let result = Self::tool_router().call(ctx).await;
4392 let elapsed = start.elapsed();
4393 tracing::debug!(
4394 tool = %tool_name,
4395 elapsed_ms = elapsed.as_millis() as u64,
4396 is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
4397 "tool invocation completed"
4398 );
4399
4400 if self.state.privacy.redaction_enabled {
4403 result.map(|mut r| {
4404 for item in &mut r.content {
4405 if let RawContent::Text(ref mut tc) = item.raw {
4406 tc.text = self.state.privacy.redact_output(&tc.text);
4407 }
4408 }
4409 r
4410 })
4411 } else {
4412 result
4413 }
4414 }
4415
4416 fn get_tool(&self, name: &str) -> Option<Tool> {
4417 if !self.state.privacy.is_tool_enabled(name) {
4418 return None;
4419 }
4420 Self::tool_router().get(name).cloned()
4421 }
4422
4423 async fn list_resources(
4424 &self,
4425 _request: Option<PaginatedRequestParams>,
4426 _context: RequestContext<RoleServer>,
4427 ) -> Result<ListResourcesResult, ErrorData> {
4428 Ok(ListResourcesResult {
4429 resources: vec![
4430 RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
4431 .with_description(
4432 "Live IPC call log — all commands invoked between frontend and backend",
4433 )
4434 .with_mime_type("application/json")
4435 .no_annotation(),
4436 RawResource::new(RESOURCE_URI_WINDOWS, "windows")
4437 .with_description(
4438 "Current state of all Tauri windows — position, size, visibility, focus",
4439 )
4440 .with_mime_type("application/json")
4441 .no_annotation(),
4442 RawResource::new(RESOURCE_URI_STATE, "state")
4443 .with_description(
4444 "Victauri plugin state — event count, registered commands, memory stats",
4445 )
4446 .with_mime_type("application/json")
4447 .no_annotation(),
4448 ],
4449 ..Default::default()
4450 })
4451 }
4452
4453 async fn read_resource(
4454 &self,
4455 request: ReadResourceRequestParams,
4456 _context: RequestContext<RoleServer>,
4457 ) -> Result<ReadResourceResult, ErrorData> {
4458 let uri = &request.uri;
4459 if let Some(cap) = resource_required_capability(uri.as_str())
4463 && !self.state.privacy.is_tool_enabled(cap)
4464 {
4465 return Err(ErrorData::invalid_request(
4466 format!("resource {uri} is not permitted by the current privacy configuration"),
4467 None,
4468 ));
4469 }
4470 let json = match uri.as_str() {
4471 RESOURCE_URI_IPC_LOG => {
4472 if let Ok(json) = self
4473 .eval_with_return("return window.__VICTAURI__?.getIpcLog()", None)
4474 .await
4475 {
4476 json
4477 } else {
4478 let calls = self.state.event_log.ipc_calls();
4479 serde_json::to_string_pretty(&calls)
4480 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4481 }
4482 }
4483 RESOURCE_URI_WINDOWS => {
4484 let states = self.bridge.get_window_states(None);
4485 serde_json::to_string_pretty(&states)
4486 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4487 }
4488 RESOURCE_URI_STATE => {
4489 let state_json = serde_json::json!({
4490 "events_captured": self.state.event_log.len(),
4491 "commands_registered": self.state.registry.count(),
4492 "memory": crate::memory::current_stats(),
4493 "port": self.state.port.load(Ordering::Relaxed),
4494 });
4495 serde_json::to_string_pretty(&state_json)
4496 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4497 }
4498 _ => {
4499 return Err(ErrorData::resource_not_found(
4500 format!("unknown resource: {uri}"),
4501 None,
4502 ));
4503 }
4504 };
4505
4506 let json = if self.state.privacy.redaction_enabled {
4507 self.state.privacy.redact_output(&json)
4508 } else {
4509 json
4510 };
4511
4512 Ok(ReadResourceResult::new(vec![ResourceContents::text(
4513 json, uri,
4514 )]))
4515 }
4516
4517 async fn subscribe(
4518 &self,
4519 request: SubscribeRequestParams,
4520 _context: RequestContext<RoleServer>,
4521 ) -> Result<(), ErrorData> {
4522 let uri = &request.uri;
4523 if let Some(cap) = resource_required_capability(uri.as_str())
4526 && !self.state.privacy.is_tool_enabled(cap)
4527 {
4528 return Err(ErrorData::invalid_request(
4529 format!("resource {uri} is not permitted by the current privacy configuration"),
4530 None,
4531 ));
4532 }
4533 match uri.as_str() {
4534 RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
4535 self.subscriptions.lock().await.insert(uri.clone());
4536 tracing::info!("Client subscribed to resource: {uri}");
4537 Ok(())
4538 }
4539 _ => Err(ErrorData::resource_not_found(
4540 format!("unknown resource: {uri}"),
4541 None,
4542 )),
4543 }
4544 }
4545
4546 async fn unsubscribe(
4547 &self,
4548 request: UnsubscribeRequestParams,
4549 _context: RequestContext<RoleServer>,
4550 ) -> Result<(), ErrorData> {
4551 self.subscriptions.lock().await.remove(&request.uri);
4552 tracing::info!("Client unsubscribed from resource: {}", request.uri);
4553 Ok(())
4554 }
4555}
4556
4557fn trimmed_log_js(source_expr: &str, limit: usize) -> String {
4564 let mb = MAX_LOG_FIELD_BYTES;
4565 format!(
4566 r"return (function() {{
4567 var MB = {mb};
4568 function trimField(v) {{
4569 if (typeof v === 'string') {{
4570 return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
4571 }}
4572 if (v && typeof v === 'object') {{
4573 var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }}
4574 if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }}
4575 }}
4576 return v;
4577 }}
4578 function trimEntry(e) {{
4579 if (e == null || typeof e !== 'object') return e;
4580 var out = Array.isArray(e) ? [] : {{}};
4581 for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) out[k] = trimField(e[k]); }}
4582 return out;
4583 }}
4584 var arr = {source_expr} || [];
4585 if (arr.length > {limit}) arr = arr.slice(-{limit});
4586 return arr.map(trimEntry);
4587 }})()"
4588 )
4589}
4590
4591fn unwrap_eval_envelope(raw: String) -> Result<String, String> {
4602 if let Ok(envelope) = serde_json::from_str::<serde_json::Value>(&raw) {
4603 if let Some(err) = envelope.get("__victauri_err") {
4604 return Err(format!(
4605 "JavaScript error: {}",
4606 err.as_str().unwrap_or("unknown error")
4607 ));
4608 }
4609 if envelope.get("__victauri_ok").is_some() {
4610 let js_type = envelope
4611 .get("__victauri_type")
4612 .and_then(|t| t.as_str())
4613 .unwrap_or("value");
4614 return match js_type {
4615 "undefined" => Ok("undefined".to_string()),
4616 "null" => Ok("null".to_string()),
4617 _ => Ok(serde_json::to_string(&envelope["__victauri_ok"])
4618 .unwrap_or_else(|_| "null".to_string())),
4619 };
4620 }
4621 }
4622 if let Some(after) = raw.strip_prefix(r#"{"__victauri_ok":"#)
4624 && let Some(idx) = after.rfind(r#","__victauri_type":"#)
4625 {
4626 return Ok(after[..idx].to_string());
4627 }
4628 if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) {
4629 let msg = after.trim_end_matches('}').trim_matches('"');
4630 return Err(format!("JavaScript error: {msg}"));
4631 }
4632 Ok(raw)
4633}
4634
4635const STMT_STARTS: &[&str] = &[
4637 "return ",
4638 "return;",
4639 "return\n",
4640 "return\t",
4641 "if ",
4642 "if(",
4643 "for ",
4644 "for(",
4645 "while ",
4646 "while(",
4647 "switch ",
4648 "switch(",
4649 "try ",
4650 "try{",
4651 "const ",
4652 "let ",
4653 "var ",
4654 "function ",
4655 "function(",
4656 "function*",
4657 "class ",
4658 "throw ",
4659 "do ",
4660 "do{",
4661 "{",
4662 "async function",
4663 "debugger",
4664];
4665
4666#[derive(PartialEq, Clone, Copy)]
4668enum ScanState {
4669 Code,
4670 SingleQuote,
4671 DoubleQuote,
4672 Template,
4673}
4674
4675fn should_prepend_return(code: &str) -> bool {
4686 use ScanState::{Code, DoubleQuote, SingleQuote, Template};
4687
4688 let code = code.trim();
4689 if code.is_empty() {
4690 return false;
4691 }
4692
4693 if STMT_STARTS.iter().any(|k| code.starts_with(k)) {
4694 return false;
4695 }
4696
4697 let bytes = code.as_bytes();
4698 let mut i = 0;
4699 let mut depth: i32 = 0;
4700 let mut state = ScanState::Code;
4701
4702 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$';
4703 let is_return_token = |i: usize| -> bool {
4705 let prev_ok = i == 0 || !is_ident(bytes[i - 1]);
4706 prev_ok
4707 && code[i..].starts_with("return")
4708 && bytes.get(i + 6).copied().is_none_or(|b| !is_ident(b))
4709 };
4710
4711 while i < bytes.len() {
4712 let c = bytes[i];
4713 match state {
4714 Code => match c {
4715 b'\'' => state = SingleQuote,
4716 b'"' => state = DoubleQuote,
4717 b'`' => state = Template,
4718 b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => {
4719 while i < bytes.len() && bytes[i] != b'\n' {
4720 i += 1;
4721 }
4722 continue;
4723 }
4724 b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
4725 i += 2;
4726 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
4727 i += 1;
4728 }
4729 i += 2;
4730 continue;
4731 }
4732 b'(' | b'[' | b'{' => depth += 1,
4733 b')' | b']' | b'}' => depth -= 1,
4734 b';' if depth <= 0 && !code[i + 1..].trim().is_empty() => return false,
4736 b'r' if depth <= 0 && is_return_token(i) => return false,
4738 _ => {}
4739 },
4740 SingleQuote => {
4741 if c == b'\\' {
4742 i += 1;
4743 } else if c == b'\'' {
4744 state = Code;
4745 }
4746 }
4747 DoubleQuote => {
4748 if c == b'\\' {
4749 i += 1;
4750 } else if c == b'"' {
4751 state = Code;
4752 }
4753 }
4754 Template => {
4755 if c == b'\\' {
4756 i += 1;
4757 } else if c == b'`' {
4758 state = Code;
4759 }
4760 }
4761 }
4762 i += 1;
4763 }
4764
4765 true
4766}
4767
4768#[cfg(test)]
4769mod prop_tests {
4770 use super::should_prepend_return;
4775 use proptest::prelude::*;
4776
4777 fn ident() -> impl Strategy<Value = String> {
4779 prop_oneof![
4780 Just("a".to_string()),
4781 Just("x".to_string()),
4782 Just("foo".to_string()),
4783 Just("window.x".to_string()),
4784 Just("document.title".to_string()),
4785 Just("obj.prop".to_string()),
4786 Just("arr[0]".to_string()),
4787 Just("localStorage".to_string()),
4788 ]
4789 }
4790
4791 fn bare_expr() -> impl Strategy<Value = String> {
4794 prop_oneof![
4795 ident(),
4796 (ident(), ident()).prop_map(|(a, b)| format!("{a} + {b}")),
4797 (ident(), ident()).prop_map(|(a, b)| format!("{a}({b})")),
4798 ident().prop_map(|a| format!("{a}.length")),
4799 any::<u16>().prop_map(|n| n.to_string()),
4800 ]
4801 }
4802
4803 proptest! {
4804 #[test]
4807 fn never_panics_on_arbitrary_input(s in ".{0,256}") {
4808 let _ = should_prepend_return(&s);
4809 }
4810
4811 #[test]
4813 fn bare_expressions_are_prepended(e in bare_expr()) {
4814 prop_assert!(should_prepend_return(&e), "bare expr not prepended: {e:?}");
4815 }
4816
4817 #[test]
4820 fn semicolon_multistatement_with_return_never_prepended(
4821 setup in bare_expr(), ret in bare_expr()
4822 ) {
4823 let code = format!("{setup}; return {ret}");
4824 prop_assert!(!should_prepend_return(&code), "would corrupt: {code:?}");
4825 }
4826
4827 #[test]
4829 fn newline_explicit_return_never_prepended(pre in bare_expr(), ret in bare_expr()) {
4830 let code = format!("{pre}\nreturn {ret}");
4831 prop_assert!(!should_prepend_return(&code), "explicit return prepended: {code:?}");
4832 }
4833
4834 #[test]
4837 fn semicolons_and_return_inside_strings_are_ignored(inner in "[a-z0-9;= ]{0,24}") {
4838 let code = format!("'do;not;split return {inner}'");
4840 prop_assert!(should_prepend_return(&code), "string literal mis-split: {code:?}");
4841 }
4842 }
4843}
4844
4845#[cfg(test)]
4846mod tests {
4847 use super::*;
4848
4849 #[test]
4850 fn env_filter_drops_secrets_keeps_safe() {
4851 assert!(is_safe_env_key("HOME"));
4853 assert!(is_safe_env_key("LANG"));
4854 assert!(is_safe_env_key("TAURI_ENV_PLATFORM"));
4855 assert!(is_safe_env_key("VICTAURI_PORT"));
4856 assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY"));
4858 assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"));
4859 assert!(!is_safe_env_key("VICTAURI_AUTH_TOKEN"));
4860 assert!(!is_safe_env_key("VICTAURI_API_KEY"));
4861 assert!(!is_safe_env_key("AWS_SECRET_ACCESS_KEY"));
4863 assert!(!is_safe_env_key("RANDOM_VAR"));
4864 assert!(!is_safe_env_key("TAURI_CUSTOM_THING"));
4867 assert!(!is_safe_env_key("VICTAURI_DB_DSN"));
4870 assert!(!is_safe_env_key("VICTAURI_SIGNING_PASSPHRASE"));
4871 assert!(!is_safe_env_key("VICTAURI_GH_PAT"));
4872 assert!(!is_safe_env_key("VICTAURI_JWT"));
4873 assert!(!is_safe_env_key("VICTAURI_SESSION_ID"));
4874 }
4875
4876 #[test]
4877 fn prepend_return_bare_expressions() {
4878 assert!(should_prepend_return("document.title"));
4879 assert!(should_prepend_return("5 + 5"));
4880 assert!(should_prepend_return("\"justexpr\""));
4881 assert!(should_prepend_return("await fetch('/x')"));
4882 assert!(should_prepend_return(
4883 "document.querySelectorAll('a').length"
4884 ));
4885 assert!(should_prepend_return("x ? a : b"));
4886 assert!(should_prepend_return("document.title;"));
4888 assert!(should_prepend_return("'a;b;c'"));
4890 assert!(should_prepend_return("\"x;y\".length"));
4891 assert!(should_prepend_return("(()=>{window.x=5; return 'ok'})()"));
4893 }
4894
4895 #[test]
4896 fn no_prepend_for_statement_blocks() {
4897 assert!(!should_prepend_return(
4899 "localStorage.setItem('k','v'); return localStorage.getItem('k')"
4900 ));
4901 assert!(!should_prepend_return(
4902 "window.scrollTo(0,50); return window.scrollY"
4903 ));
4904 assert!(!should_prepend_return("console.log('x'); return 123"));
4905 assert!(!should_prepend_return("window.__z=7; return 'ok'"));
4906 assert!(!should_prepend_return("window.x = 5\nreturn window.x"));
4908 }
4909
4910 #[test]
4911 fn no_prepend_for_statement_keywords() {
4912 assert!(!should_prepend_return("return 42"));
4913 assert!(!should_prepend_return("const x = 1; return x"));
4914 assert!(!should_prepend_return("let y = 2"));
4915 assert!(!should_prepend_return("var z = 3"));
4916 assert!(!should_prepend_return("if (x) { return 1 }"));
4917 assert!(!should_prepend_return("for (const x of y) doThing(x)"));
4918 assert!(!should_prepend_return("throw new Error('x')"));
4919 assert!(!should_prepend_return("function f(){}"));
4920 assert!(!should_prepend_return("{ a: 1 }")); }
4922
4923 #[test]
4924 fn empty_code_no_prepend() {
4925 assert!(!should_prepend_return(""));
4926 assert!(!should_prepend_return(" "));
4927 }
4928
4929 #[test]
4930 fn envelope_unwrap_value() {
4931 assert_eq!(
4932 unwrap_eval_envelope(r#"{"__victauri_ok":"4DA","__victauri_type":"value"}"#.into()),
4933 Ok("\"4DA\"".to_string())
4934 );
4935 assert_eq!(
4936 unwrap_eval_envelope(r#"{"__victauri_ok":42,"__victauri_type":"value"}"#.into()),
4937 Ok("42".to_string())
4938 );
4939 }
4940
4941 #[test]
4942 fn envelope_unwrap_undefined_null() {
4943 assert_eq!(
4944 unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#.into()),
4945 Ok("undefined".to_string())
4946 );
4947 assert_eq!(
4948 unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"null"}"#.into()),
4949 Ok("null".to_string())
4950 );
4951 }
4952
4953 #[test]
4954 fn envelope_unwrap_error() {
4955 let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into());
4956 assert!(r.unwrap_err().contains("boom"));
4957 }
4958
4959 #[test]
4960 fn envelope_unwrap_deeply_nested_does_not_leak() {
4961 let mut value = String::from("0");
4965 for _ in 0..300 {
4966 value = format!("{{\"n\":{value}}}");
4967 }
4968 let raw = format!(r#"{{"__victauri_ok":{value},"__victauri_type":"value"}}"#);
4969 let out = unwrap_eval_envelope(raw).unwrap();
4970 assert!(
4971 out.starts_with(r#"{"n":"#),
4972 "deep value should be unwrapped, got: {}",
4973 &out[..out.len().min(40)]
4974 );
4975 assert!(
4976 !out.contains("__victauri_ok"),
4977 "envelope must not leak into the result"
4978 );
4979 }
4980
4981 #[test]
4982 fn js_string_simple() {
4983 assert_eq!(js_string("hello"), "\"hello\"");
4984 }
4985
4986 #[test]
4987 fn js_string_single_quotes() {
4988 let result = js_string("it's a test");
4989 assert!(result.contains("it's a test"));
4990 }
4991
4992 #[test]
4993 fn js_string_double_quotes() {
4994 let result = js_string(r#"say "hello""#);
4995 assert!(result.contains(r#"\""#));
4996 }
4997
4998 #[test]
4999 fn js_string_backslashes() {
5000 let result = js_string(r"path\to\file");
5001 assert!(result.contains(r"\\"));
5002 }
5003
5004 #[test]
5005 fn js_string_newlines_and_tabs() {
5006 let result = js_string("line1\nline2\ttab");
5007 assert!(result.contains(r"\n"));
5008 assert!(result.contains(r"\t"));
5009 assert!(!result.contains('\n'));
5010 }
5011
5012 #[test]
5013 fn js_string_null_bytes() {
5014 let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
5015 let result = js_string(&input);
5016 assert!(result.contains("\\u0000"));
5018 assert!(!result.contains('\0'));
5019 }
5020
5021 #[test]
5022 fn js_string_template_literal_injection() {
5023 let result = js_string("`${alert(1)}`");
5024 assert!(result.starts_with('"'));
5027 assert!(result.ends_with('"'));
5028 }
5029
5030 #[test]
5031 fn js_string_unicode_separators() {
5032 let result = js_string("a\u{2028}b\u{2029}c");
5037 let decoded: String = serde_json::from_str(&result).unwrap();
5039 assert_eq!(decoded, "a\u{2028}b\u{2029}c");
5040 }
5041
5042 #[test]
5043 fn js_string_empty() {
5044 assert_eq!(js_string(""), "\"\"");
5045 }
5046
5047 #[test]
5048 fn js_string_html_script_close() {
5049 let result = js_string("</script><img onerror=alert(1)>");
5051 assert!(result.starts_with('"'));
5052 let decoded: String = serde_json::from_str(&result).unwrap();
5054 assert_eq!(decoded, "</script><img onerror=alert(1)>");
5055 }
5056
5057 #[test]
5058 fn js_string_very_long() {
5059 let long = "a".repeat(100_000);
5060 let result = js_string(&long);
5061 assert!(result.len() >= 100_002); }
5063
5064 #[test]
5067 fn url_allows_http() {
5068 assert!(validate_url("http://example.com", false).is_ok());
5069 }
5070
5071 #[test]
5072 fn url_allows_https() {
5073 assert!(validate_url("https://example.com/path?q=1", false).is_ok());
5074 }
5075
5076 #[test]
5077 fn url_allows_http_localhost() {
5078 assert!(validate_url("http://localhost:3000", false).is_ok());
5079 }
5080
5081 #[test]
5082 fn url_blocks_file_by_default() {
5083 let err = validate_url("file:///etc/passwd", false).unwrap_err();
5084 assert!(err.contains("file"), "error should mention the file scheme");
5085 }
5086
5087 #[test]
5088 fn url_allows_file_when_opted_in() {
5089 assert!(validate_url("file:///tmp/test.html", true).is_ok());
5090 }
5091
5092 #[test]
5093 fn url_blocks_javascript() {
5094 assert!(validate_url("javascript:alert(1)", false).is_err());
5095 }
5096
5097 #[test]
5098 fn url_blocks_javascript_case_insensitive() {
5099 assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
5100 }
5101
5102 #[test]
5103 fn url_blocks_data_scheme() {
5104 assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
5105 }
5106
5107 #[test]
5108 fn url_blocks_vbscript() {
5109 assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
5110 }
5111
5112 #[test]
5113 fn url_rejects_invalid() {
5114 assert!(validate_url("not a url at all", false).is_err());
5115 }
5116
5117 #[test]
5118 fn url_strips_control_chars() {
5119 let input = format!("http://example{}com", '\0');
5121 assert!(validate_url(&input, false).is_ok());
5122 }
5123
5124 #[test]
5127 fn css_color_valid_hex() {
5128 assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
5129 assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
5130 assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
5131 }
5132
5133 #[test]
5134 fn css_color_valid_rgb() {
5135 assert_eq!(
5136 sanitize_css_color("rgb(255, 0, 0)").unwrap(),
5137 "rgb(255, 0, 0)"
5138 );
5139 assert_eq!(
5140 sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
5141 "rgba(0, 0, 0, 0.5)"
5142 );
5143 }
5144
5145 #[test]
5146 fn css_color_valid_named() {
5147 assert_eq!(sanitize_css_color("red").unwrap(), "red");
5148 assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
5149 }
5150
5151 #[test]
5152 fn css_color_valid_hsl() {
5153 assert_eq!(
5154 sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
5155 "hsl(120, 50%, 50%)"
5156 );
5157 }
5158
5159 #[test]
5160 fn css_color_rejects_too_long() {
5161 let long = "a".repeat(101);
5162 assert!(sanitize_css_color(&long).is_err());
5163 }
5164
5165 #[test]
5166 fn css_color_rejects_backslash_escapes() {
5167 assert!(sanitize_css_color(r"red\00").is_err());
5168 assert!(sanitize_css_color(r"\72\65\64").is_err());
5169 }
5170
5171 #[test]
5172 fn css_color_rejects_url_injection() {
5173 assert!(sanitize_css_color("url(http://evil.com)").is_err());
5174 assert!(sanitize_css_color("URL(http://evil.com)").is_err());
5175 }
5176
5177 #[test]
5178 fn css_color_rejects_expression_injection() {
5179 assert!(sanitize_css_color("expression(alert(1))").is_err());
5180 assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
5181 }
5182
5183 #[test]
5184 fn css_color_rejects_import() {
5185 assert!(sanitize_css_color("@import url(evil.css)").is_err());
5186 }
5187
5188 #[test]
5189 fn css_color_rejects_semicolons_and_braces() {
5190 assert!(sanitize_css_color("red; background: url(evil)").is_err());
5191 assert!(sanitize_css_color("red} body { color: blue").is_err());
5192 }
5193
5194 #[test]
5195 fn css_color_rejects_special_chars() {
5196 assert!(sanitize_css_color("red<script>").is_err());
5197 assert!(sanitize_css_color("red\"onload=alert").is_err());
5198 assert!(sanitize_css_color("red'onclick=alert").is_err());
5199 }
5200
5201 #[test]
5202 fn css_color_trims_whitespace() {
5203 assert_eq!(sanitize_css_color(" red ").unwrap(), "red");
5204 }
5205
5206 #[test]
5207 fn css_color_empty_string() {
5208 assert_eq!(sanitize_css_color("").unwrap(), "");
5209 }
5210}
5211
5212#[cfg(test)]
5221mod authz_dispatch_tests {
5222 use super::*;
5223 use crate::bridge::WebviewBridge;
5224 use crate::privacy::PrivacyConfig;
5225 use std::collections::{HashMap, HashSet};
5226 use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
5227
5228 struct RejectingBridge;
5232
5233 impl WebviewBridge for RejectingBridge {
5234 fn eval_webview(&self, _label: Option<&str>, _script: &str) -> Result<(), String> {
5235 Err("eval rejected in authz dispatch test".to_string())
5236 }
5237 fn get_window_states(&self, _label: Option<&str>) -> Vec<WindowState> {
5238 Vec::new()
5239 }
5240 fn list_window_labels(&self) -> Vec<String> {
5241 Vec::new()
5242 }
5243 fn get_native_handle(&self, _label: Option<&str>) -> Result<isize, String> {
5244 Err("no handle".to_string())
5245 }
5246 fn manage_window(&self, _label: Option<&str>, _action: &str) -> Result<String, String> {
5247 Err("no window".to_string())
5248 }
5249 fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
5250 Ok(())
5251 }
5252 fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
5253 Ok(())
5254 }
5255 fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
5256 Ok(())
5257 }
5258 }
5259
5260 fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
5261 Arc::new(VictauriState {
5262 event_log: EventLog::new(1000),
5263 registry: CommandRegistry::new(),
5264 port: std::sync::atomic::AtomicU16::new(0),
5265 pending_evals: Arc::new(Mutex::new(HashMap::new())),
5266 recorder: EventRecorder::new(1000),
5267 privacy,
5268 eval_timeout: std::time::Duration::from_millis(100),
5269 shutdown_tx: tokio::sync::watch::channel(false).0,
5270 started_at: std::time::Instant::now(),
5271 tool_invocations: std::sync::atomic::AtomicU64::new(0),
5272 allow_file_navigation: false,
5273 command_timings: crate::introspection::CommandTimings::new(),
5274 fault_registry: crate::introspection::FaultRegistry::new(),
5275 contract_store: crate::introspection::ContractStore::new(),
5276 startup_timeline: crate::introspection::StartupTimeline::new(),
5277 event_bus: crate::introspection::EventBusMonitor::default(),
5278 task_tracker: crate::introspection::TaskTracker::new(),
5279 bridge_ready: std::sync::atomic::AtomicBool::new(true),
5280 bridge_notify: tokio::sync::Notify::new(),
5281 db_search_paths: Vec::new(),
5282 screencast: Arc::new(crate::screencast::Screencast::default()),
5283 probes: crate::introspection::AppStateProbes::default(),
5284 })
5285 }
5286
5287 fn handler(privacy: PrivacyConfig) -> VictauriMcpHandler {
5288 VictauriMcpHandler::new(state_with(privacy), Arc::new(RejectingBridge))
5289 }
5290
5291 fn is_privacy_blocked(r: &CallToolResult) -> bool {
5293 r.is_error == Some(true)
5294 && r.content.iter().any(|c| {
5295 matches!(&c.raw, RawContent::Text(t)
5296 if t.text.contains("disabled by privacy configuration"))
5297 })
5298 }
5299
5300 async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
5301 match h.execute_tool(tool, args).await {
5302 Ok(r) => r,
5303 Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
5304 }
5305 }
5306
5307 #[tokio::test]
5310 async fn observe_blocks_mutations_and_eval_through_dispatch() {
5311 let h = handler(crate::privacy::observe_privacy_config());
5312 let blocked: &[(&str, serde_json::Value)] = &[
5313 ("eval_js", serde_json::json!({"code": "1"})),
5314 ("screenshot", serde_json::json!({})),
5315 ("invoke_command", serde_json::json!({"command": "greet"})),
5316 ("verify_state", serde_json::json!({"frontend_expr": "1"})),
5317 (
5318 "assert_semantic",
5319 serde_json::json!({"expression": "1", "condition": "truthy"}),
5320 ),
5321 (
5322 "interact",
5323 serde_json::json!({"action": "click", "ref_id": "e1"}),
5324 ),
5325 (
5326 "input",
5327 serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
5328 ),
5329 (
5330 "storage",
5331 serde_json::json!({"action": "set", "key": "k", "value": "v"}),
5332 ),
5333 (
5334 "storage",
5335 serde_json::json!({"action": "delete", "key": "k"}),
5336 ),
5337 (
5338 "window",
5339 serde_json::json!({"action": "manage", "manage_action": "close"}),
5340 ),
5341 (
5342 "window",
5343 serde_json::json!({"action": "set_title", "title": "x"}),
5344 ),
5345 (
5346 "navigate",
5347 serde_json::json!({"action": "go_to", "url": "https://e.com"}),
5348 ),
5349 (
5350 "css",
5351 serde_json::json!({"action": "inject", "css": "body{}"}),
5352 ),
5353 ("route", serde_json::json!({"action": "clear_all"})),
5354 ("recording", serde_json::json!({"action": "start"})),
5355 ("recording", serde_json::json!({"action": "replay"})),
5356 ("logs", serde_json::json!({"action": "clear"})),
5357 (
5358 "fault",
5359 serde_json::json!({"action": "inject", "command": "x", "fault_type": "error"}),
5360 ),
5361 (
5362 "introspect",
5363 serde_json::json!({"action": "command_timings"}),
5364 ),
5365 ];
5366 for (tool, args) in blocked {
5367 let r = call(&h, tool, args.clone()).await;
5368 assert!(
5369 is_privacy_blocked(&r),
5370 "Observe must block {tool} {args} at dispatch, got: {:?}",
5371 r.content
5372 );
5373 }
5374 }
5375
5376 #[tokio::test]
5377 async fn observe_allows_read_only_through_dispatch() {
5378 let h = handler(crate::privacy::observe_privacy_config());
5379 let allowed: &[(&str, serde_json::Value)] = &[
5382 ("get_registry", serde_json::json!({})),
5383 ("get_memory_stats", serde_json::json!({})),
5384 ("window", serde_json::json!({"action": "list"})),
5385 ("logs", serde_json::json!({"action": "ipc"})),
5386 (
5387 "inspect",
5388 serde_json::json!({"action": "get_styles", "ref_id": "e1"}),
5389 ),
5390 ];
5391 for (tool, args) in allowed {
5392 let r = call(&h, tool, args.clone()).await;
5393 assert!(
5394 !is_privacy_blocked(&r),
5395 "Observe must allow {tool} {args} at dispatch (blocked unexpectedly)"
5396 );
5397 }
5398 }
5399
5400 #[tokio::test]
5403 async fn test_profile_dispatch_boundaries() {
5404 let h = handler(crate::privacy::test_privacy_config());
5405 for (tool, args) in [
5407 (
5408 "interact",
5409 serde_json::json!({"action": "click", "ref_id": "e1"}),
5410 ),
5411 (
5412 "input",
5413 serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
5414 ),
5415 (
5416 "storage",
5417 serde_json::json!({"action": "set", "key": "k", "value": "v"}),
5418 ),
5419 ("navigate", serde_json::json!({"action": "go_back"})),
5420 ("recording", serde_json::json!({"action": "start"})),
5421 ("logs", serde_json::json!({"action": "clear"})),
5422 ] {
5423 let r = call(&h, tool, args.clone()).await;
5424 assert!(!is_privacy_blocked(&r), "Test must allow {tool} {args}");
5425 }
5426 for (tool, args) in [
5428 ("eval_js", serde_json::json!({"code": "1"})),
5429 ("verify_state", serde_json::json!({"frontend_expr": "1"})),
5430 (
5431 "navigate",
5432 serde_json::json!({"action": "go_to", "url": "https://e.com"}),
5433 ),
5434 ("recording", serde_json::json!({"action": "replay"})),
5435 (
5436 "route",
5437 serde_json::json!({"action": "add", "pattern": "x"}),
5438 ),
5439 ("css", serde_json::json!({"action": "inject", "css": "x"})),
5440 (
5441 "window",
5442 serde_json::json!({"action": "set_title", "title": "x"}),
5443 ),
5444 ] {
5445 let r = call(&h, tool, args.clone()).await;
5446 assert!(is_privacy_blocked(&r), "Test must block {tool} {args}");
5447 }
5448 }
5449
5450 #[tokio::test]
5455 async fn disabling_bare_compound_tool_blocks_all_actions() {
5456 let cfg = PrivacyConfig {
5457 disabled_tools: HashSet::from(["recording".to_string()]),
5458 ..Default::default()
5459 }; let h = handler(cfg);
5461 for action in ["start", "stop", "replay", "import", "export"] {
5462 let r = call(&h, "recording", serde_json::json!({"action": action})).await;
5463 assert!(
5464 is_privacy_blocked(&r),
5465 "disabling bare `recording` must block recording.{action}"
5466 );
5467 }
5468 }
5469
5470 #[tokio::test]
5471 async fn disabling_specific_action_is_honored_at_dispatch() {
5472 let cfg = PrivacyConfig {
5476 disabled_tools: HashSet::from([
5477 "route.clear".to_string(),
5478 "route.clear_all".to_string(),
5479 ]),
5480 ..Default::default()
5481 }; let h = handler(cfg);
5483
5484 let blocked = call(&h, "route", serde_json::json!({"action": "clear", "id": 1})).await;
5485 assert!(is_privacy_blocked(&blocked), "route.clear must be blocked");
5486 let blocked_all = call(&h, "route", serde_json::json!({"action": "clear_all"})).await;
5487 assert!(
5488 is_privacy_blocked(&blocked_all),
5489 "route.clear_all must be blocked"
5490 );
5491
5492 let allowed = call(&h, "route", serde_json::json!({"action": "list"})).await;
5494 assert!(
5495 !is_privacy_blocked(&allowed),
5496 "route.list must remain allowed"
5497 );
5498 }
5499
5500 #[tokio::test]
5506 async fn full_control_allows_everything_at_dispatch() {
5507 let h = handler(PrivacyConfig::default());
5508 for (tool, args) in [
5509 ("recording", serde_json::json!({"action": "replay"})),
5510 ("route", serde_json::json!({"action": "clear_all"})),
5511 ("eval_js", serde_json::json!({"code": "1"})),
5512 ("fault", serde_json::json!({"action": "list"})),
5513 ] {
5514 let r = call(&h, tool, args.clone()).await;
5515 assert!(
5516 !is_privacy_blocked(&r),
5517 "FullControl must allow {tool} {args}"
5518 );
5519 }
5520 }
5521}
5522
5523#[cfg(test)]
5537mod command_policy_dispatch_tests {
5538 use super::*;
5539 use crate::bridge::WebviewBridge;
5540 use crate::privacy::PrivacyConfig;
5541 use serde_json::json;
5542 use std::collections::{HashMap, HashSet};
5543 use std::sync::Mutex as StdMutex;
5544 use victauri_core::{
5545 AppEvent, CommandRegistry, EventLog, EventRecorder, IpcCall, IpcResult, RecordedEvent,
5546 RecordedSession, WindowState,
5547 };
5548
5549 #[derive(Clone, Default)]
5553 struct RecordingBridge {
5554 scripts: Arc<StdMutex<Vec<String>>>,
5555 }
5556
5557 impl RecordingBridge {
5558 fn invoked(&self, command: &str) -> bool {
5560 let needle = format!("invoke({}", js_string(command));
5561 self.scripts
5562 .lock()
5563 .unwrap_or_else(std::sync::PoisonError::into_inner)
5564 .iter()
5565 .any(|s| s.contains(&needle))
5566 }
5567 }
5568
5569 impl WebviewBridge for RecordingBridge {
5570 fn eval_webview(&self, _label: Option<&str>, script: &str) -> Result<(), String> {
5571 self.scripts
5572 .lock()
5573 .unwrap_or_else(std::sync::PoisonError::into_inner)
5574 .push(script.to_string());
5575 Ok(())
5580 }
5581 fn get_window_states(&self, _l: Option<&str>) -> Vec<WindowState> {
5582 Vec::new()
5583 }
5584 fn list_window_labels(&self) -> Vec<String> {
5585 Vec::new()
5586 }
5587 fn get_native_handle(&self, _l: Option<&str>) -> Result<isize, String> {
5588 Err("no handle".to_string())
5589 }
5590 fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
5591 Err("no window".to_string())
5592 }
5593 fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
5594 Ok(())
5595 }
5596 fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
5597 Ok(())
5598 }
5599 fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
5600 Ok(())
5601 }
5602 }
5603
5604 fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
5605 Arc::new(VictauriState {
5606 event_log: EventLog::new(1000),
5607 registry: CommandRegistry::new(),
5608 port: std::sync::atomic::AtomicU16::new(0),
5609 pending_evals: Arc::new(Mutex::new(HashMap::new())),
5610 recorder: EventRecorder::new(1000),
5611 privacy,
5612 eval_timeout: std::time::Duration::from_millis(100),
5613 shutdown_tx: tokio::sync::watch::channel(false).0,
5614 started_at: std::time::Instant::now(),
5615 tool_invocations: std::sync::atomic::AtomicU64::new(0),
5616 allow_file_navigation: false,
5617 command_timings: crate::introspection::CommandTimings::new(),
5618 fault_registry: crate::introspection::FaultRegistry::new(),
5619 contract_store: crate::introspection::ContractStore::new(),
5620 startup_timeline: crate::introspection::StartupTimeline::new(),
5621 event_bus: crate::introspection::EventBusMonitor::default(),
5622 task_tracker: crate::introspection::TaskTracker::new(),
5623 bridge_ready: std::sync::atomic::AtomicBool::new(true),
5624 bridge_notify: tokio::sync::Notify::new(),
5625 db_search_paths: Vec::new(),
5626 screencast: Arc::new(crate::screencast::Screencast::default()),
5627 probes: crate::introspection::AppStateProbes::default(),
5628 })
5629 }
5630
5631 fn blocking(cmds: &[&str]) -> PrivacyConfig {
5635 PrivacyConfig {
5636 command_blocklist: cmds.iter().map(|s| (*s).to_string()).collect(),
5637 ..Default::default()
5638 }
5639 }
5640
5641 fn ipc_event(command: &str) -> AppEvent {
5642 AppEvent::Ipc(IpcCall {
5643 id: format!("c-{command}"),
5644 command: command.to_string(),
5645 timestamp: chrono::Utc::now(),
5646 duration_ms: Some(1),
5647 result: IpcResult::Ok(json!(true)),
5648 arg_size_bytes: 0,
5649 webview_label: "main".to_string(),
5650 })
5651 }
5652
5653 fn result_text(r: &CallToolResult) -> String {
5654 r.content
5655 .iter()
5656 .filter_map(|c| match &c.raw {
5657 RawContent::Text(t) => Some(t.text.clone()),
5658 _ => None,
5659 })
5660 .collect::<Vec<_>>()
5661 .join("\n")
5662 }
5663
5664 async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
5665 match h.execute_tool(tool, args).await {
5666 Ok(r) => r,
5667 Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
5668 }
5669 }
5670
5671 #[tokio::test]
5674 async fn replay_never_invokes_a_blocklisted_command() {
5675 let bridge = RecordingBridge::default();
5676 let state = state_with(blocking(&["delete_account"]));
5677 state.recorder.start("s1".to_string()).unwrap();
5678 state.recorder.record_event(ipc_event("delete_account"));
5679 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
5680
5681 let r = call(&h, "recording", json!({"action": "replay"})).await;
5682
5683 assert!(
5684 !bridge.invoked("delete_account"),
5685 "SIDE-EFFECT LEAK: replay handed a blocklisted command's invoke to the bridge (audit #30/#31)"
5686 );
5687 assert!(
5688 result_text(&r).contains("blocked"),
5689 "replay should report the command as blocked, got: {}",
5690 result_text(&r)
5691 );
5692 }
5693
5694 #[tokio::test]
5695 async fn replay_does_invoke_an_allowed_command() {
5696 let bridge = RecordingBridge::default();
5699 let state = state_with(PrivacyConfig::default());
5700 state.recorder.start("s1".to_string()).unwrap();
5701 state.recorder.record_event(ipc_event("greet"));
5702 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
5703
5704 let _ = call(&h, "recording", json!({"action": "replay"})).await;
5705
5706 assert!(
5707 bridge.invoked("greet"),
5708 "positive control failed: an ALLOWED command was not invoked, so the negative test proves nothing"
5709 );
5710 }
5711
5712 #[tokio::test]
5713 async fn imported_session_cannot_invoke_a_blocklisted_command() {
5714 let bridge = RecordingBridge::default();
5717 let state = state_with(blocking(&["wipe_database"]));
5718 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
5719
5720 let session = RecordedSession {
5721 id: "poisoned".to_string(),
5722 started_at: chrono::Utc::now(),
5723 events: vec![RecordedEvent {
5724 index: 0,
5725 timestamp: chrono::Utc::now(),
5726 event: ipc_event("wipe_database"),
5727 }],
5728 checkpoints: Vec::new(),
5729 };
5730 let session_json = serde_json::to_string(&session).unwrap();
5731
5732 let imp = call(
5733 &h,
5734 "recording",
5735 json!({"action": "import", "session_json": session_json}),
5736 )
5737 .await;
5738 assert_ne!(
5739 imp.is_error,
5740 Some(true),
5741 "import itself should succeed: {}",
5742 result_text(&imp)
5743 );
5744
5745 let r = call(&h, "recording", json!({"action": "replay"})).await;
5746 assert!(
5747 !bridge.invoked("wipe_database"),
5748 "SIDE-EFFECT LEAK: an imported session replayed a blocklisted command (audit #31)"
5749 );
5750 assert!(result_text(&r).contains("blocked"));
5751 }
5752
5753 #[tokio::test]
5756 async fn contract_record_never_invokes_a_blocklisted_command() {
5757 let bridge = RecordingBridge::default();
5758 let state = state_with(blocking(&["delete_account"]));
5759 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
5760
5761 let r = call(
5762 &h,
5763 "introspect",
5764 json!({"action": "contract_record", "command": "delete_account", "args": {"confirm": true}}),
5765 )
5766 .await;
5767
5768 assert!(
5769 !bridge.invoked("delete_account"),
5770 "SIDE-EFFECT LEAK: contract_record invoked a blocklisted command (audit #30)"
5771 );
5772 assert_eq!(r.is_error, Some(true));
5773 assert!(
5774 result_text(&r).contains("blocked by privacy configuration"),
5775 "got: {}",
5776 result_text(&r)
5777 );
5778 }
5779
5780 #[tokio::test]
5781 async fn contract_record_does_invoke_an_allowed_command() {
5782 let bridge = RecordingBridge::default();
5783 let state = state_with(PrivacyConfig::default());
5784 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
5785
5786 let _ = call(
5787 &h,
5788 "introspect",
5789 json!({"action": "contract_record", "command": "get_settings"}),
5790 )
5791 .await;
5792
5793 assert!(
5794 bridge.invoked("get_settings"),
5795 "positive control failed: contract_record did not invoke an allowed command"
5796 );
5797 }
5798
5799 #[tokio::test]
5800 async fn contract_check_never_reinvokes_a_now_blocklisted_command() {
5801 let bridge = RecordingBridge::default();
5804 let state = state_with(blocking(&["delete_account"]));
5805 state
5806 .contract_store
5807 .record(crate::introspection::ContractBaseline {
5808 command: "delete_account".to_string(),
5809 args: json!({}),
5810 shape: crate::introspection::JsonShape::from_value(&json!(true)),
5811 sample: "true".to_string(),
5812 recorded_at: chrono_now(),
5813 });
5814 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
5815
5816 let _ = call(&h, "introspect", json!({"action": "contract_check"})).await;
5817
5818 assert!(
5819 !bridge.invoked("delete_account"),
5820 "SIDE-EFFECT LEAK: contract_check re-invoked a now-blocklisted command (audit #30)"
5821 );
5822 }
5823
5824 #[test]
5827 fn resource_reads_are_gated_by_their_mirrored_capability() {
5828 let cfg = PrivacyConfig {
5831 disabled_tools: HashSet::from([
5832 "logs.ipc".to_string(),
5833 "window.list".to_string(),
5834 "get_plugin_info".to_string(),
5835 ]),
5836 ..Default::default()
5837 };
5838 for uri in [
5839 RESOURCE_URI_IPC_LOG,
5840 RESOURCE_URI_WINDOWS,
5841 RESOURCE_URI_STATE,
5842 ] {
5843 let cap = resource_required_capability(uri).expect("resource maps to a capability");
5844 assert!(
5845 !cfg.is_tool_enabled(cap),
5846 "disabling capability {cap} must gate resource {uri} (audit B1)"
5847 );
5848 }
5849 let full = PrivacyConfig::default();
5851 for uri in [
5852 RESOURCE_URI_IPC_LOG,
5853 RESOURCE_URI_WINDOWS,
5854 RESOURCE_URI_STATE,
5855 ] {
5856 assert!(full.is_tool_enabled(resource_required_capability(uri).unwrap()));
5857 }
5858 }
5859
5860 #[tokio::test]
5863 async fn empty_auth_token_collapses_to_no_auth() {
5864 use http_body_util::BodyExt;
5865 use tower::ServiceExt;
5866
5867 for token in [Some(String::new()), Some(" ".to_string())] {
5868 let app = crate::mcp::server::build_app_full(
5869 state_with(PrivacyConfig::default()),
5870 Arc::new(RecordingBridge::default()),
5871 token.clone(),
5872 None,
5873 );
5874 let req = axum::extract::Request::builder()
5875 .uri("/info")
5876 .header("host", "127.0.0.1")
5877 .body(axum::body::Body::empty())
5878 .unwrap();
5879 let resp = app.oneshot(req).await.unwrap();
5880 assert_eq!(
5881 resp.status(),
5882 200,
5883 "/info must be reachable with empty token {token:?} (no auth layer)"
5884 );
5885 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
5886 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5887 assert_eq!(
5888 body["auth_required"],
5889 json!(false),
5890 "empty/whitespace token must report auth_required:false, not looks-protected-isnt (audit B2); token={token:?}"
5891 );
5892 }
5893 }
5894
5895 #[test]
5898 fn is_safe_env_key_drops_secrets_keeps_safe() {
5899 for secret in [
5900 "VICTAURI_AUTH_TOKEN",
5901 "TAURI_SIGNING_PRIVATE_KEY",
5902 "TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
5903 "CARGO_REGISTRY_TOKEN",
5904 "AWS_SECRET_ACCESS_KEY",
5905 "DATABASE_DSN",
5906 "GH_PAT",
5907 ] {
5908 assert!(
5909 !is_safe_env_key(secret),
5910 "{secret} is secret-shaped and must NOT be surfaced by app_info (audit #5)"
5911 );
5912 }
5913 for safe in [
5914 "HOME",
5915 "LANG",
5916 "TERM",
5917 "XDG_RUNTIME_DIR",
5918 "TAURI_ENV_PLATFORM",
5919 ] {
5920 assert!(
5921 is_safe_env_key(safe),
5922 "{safe} should be surfaced by app_info"
5923 );
5924 }
5925 }
5926}