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