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