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