1pub(crate) mod authz;
8mod backend_log_tools;
9mod backend_params;
10mod bounded;
11mod compound_params;
12#[cfg(test)]
13mod drain_tests;
14mod hardening;
15mod helpers;
16mod introspection_params;
17mod other_params;
18pub(crate) mod page_json;
19mod rest;
20#[cfg(test)]
21mod robustness_tests;
22mod server;
23#[cfg(test)]
24mod tool_result_tests;
25mod verification_params;
26mod webview_params;
27mod window_params;
28
29use std::collections::{HashMap, HashSet};
30use std::sync::Arc;
31use std::sync::atomic::{AtomicBool, Ordering};
32
33use rmcp::handler::server::tool::ToolCallContext;
34use rmcp::handler::server::wrapper::Parameters;
35use rmcp::model::{
36 CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock,
37 InitializeResult, ListResourcesResult, ListToolsResult, PaginatedRequestParams,
38 ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, Resource,
39 ResourceContents, ServerCapabilities, SubscribeRequestParams, Tool, UnsubscribeRequestParams,
40};
41use rmcp::service::RequestContext;
42use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_router};
43use tokio::sync::Mutex;
44
45use crate::VictauriState;
46use crate::bridge::WebviewBridge;
47
48use helpers::{
49 RecoveryHint, build_ghost_report, ghost_ipc_outcomes_js, ghost_ipc_projection_js,
50 ipc_catalog_projection_js, ipc_timing_projection_js, ipc_timing_stats, js_string, json_result,
51 json_truthy, merge_command_catalog, missing_param, sanitize_css_color, sanitize_injected_css,
52 tool_disabled, tool_error, tool_error_with_hint, truncate_at_char_boundary, validate_url,
53};
54
55pub(crate) use backend_params::*;
62pub(crate) use compound_params::*;
63#[doc(hidden)]
66pub use helpers::{trusted_click_probe_js, trusted_focus_probe_js};
67
68#[doc(hidden)]
73pub mod ipc_log_js {
74 pub use super::helpers::{
75 ghost_ipc_outcomes_js, ipc_catalog_projection_js, ipc_timing_projection_js,
76 };
77 pub use super::{ipc_integrity_js, slow_ipc_js, trimmed_log_js};
78}
79pub(crate) use introspection_params::*;
80pub(crate) use other_params::{
81 AppStateParams, DiagnosticsParams, FindElementsParams, ResolveCommandParams,
82 SemanticAssertParams, WaitCondition, WaitForParams,
83};
84pub use server::*;
85pub(crate) use verification_params::*;
86pub(crate) use webview_params::*;
87pub(crate) use window_params::*;
88
89pub(crate) const MAX_PENDING_EVALS: usize = 100;
94
95fn chrono_now() -> String {
96 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
97}
98
99const MAX_EVAL_CODE_LEN: usize = 1_000_000;
101
102const MAX_EVAL_RESULT_LEN: usize = 5_000_000;
105
106const DEFAULT_LOG_LIMIT: usize = 100;
109
110const MAX_LOG_FIELD_BYTES: usize = 4096;
114
115const MAX_DIR_ENTRIES: usize = 10_000;
120const MAX_DIR_VISITED: usize = 100_000;
123const MAX_DIR_WALK_TIME: std::time::Duration = std::time::Duration::from_secs(5);
125
126struct DirWalk {
129 canon_base: std::path::PathBuf,
131 pattern: Option<String>,
132 max_depth: u32,
133 deadline: std::time::Instant,
134 max_visited: usize,
135 visited: usize,
136 truncated: bool,
137 entries: Vec<serde_json::Value>,
138}
139
140impl DirWalk {
141 fn out_of_budget(&mut self) -> bool {
142 if self.entries.len() >= MAX_DIR_ENTRIES
143 || self.visited >= self.max_visited
144 || std::time::Instant::now() >= self.deadline
145 {
146 self.truncated = true;
147 }
148 self.truncated
149 }
150
151 fn visit(&mut self, dir: &std::path::Path, base: &std::path::Path, depth: u32) {
152 let Ok(read_dir) = std::fs::read_dir(dir) else {
153 return;
154 };
155 for entry in read_dir.flatten() {
156 if self.out_of_budget() {
157 return;
158 }
159 self.visited += 1;
160 let path = entry.path();
161 if path.is_symlink() {
162 continue;
163 }
164 if !std::fs::canonicalize(&path).is_ok_and(|c| c.starts_with(&self.canon_base)) {
168 continue;
169 }
170 let name = entry.file_name().to_string_lossy().into_owned();
171 let is_dir = path.is_dir();
172 if let Some(pat) = self.pattern.as_deref()
173 && !is_dir
174 && !VictauriMcpHandler::matches_glob(&name, pat)
175 {
176 continue;
177 }
178 let relative = path
179 .strip_prefix(base)
180 .unwrap_or(&path)
181 .to_string_lossy()
182 .into_owned();
183 let meta = std::fs::metadata(&path).ok();
184
185 self.entries.push(serde_json::json!({
186 "name": name,
187 "path": relative,
188 "is_dir": is_dir,
189 "size": meta.as_ref().map(std::fs::Metadata::len),
190 "modified": meta.as_ref()
191 .and_then(|m| m.modified().ok())
192 .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
193 .unwrap_or_default().as_secs()),
194 }));
195
196 if is_dir && depth < self.max_depth {
197 self.visit(&path, base, depth + 1);
198 }
199 }
200 }
201}
202
203#[cfg(feature = "sqlite")]
209const DB_HEALTH_COUNT_BUDGET: std::time::Duration = std::time::Duration::from_secs(5);
210#[cfg(feature = "sqlite")]
211const DB_HEALTH_CHECK_BUDGET: std::time::Duration = std::time::Duration::from_secs(5);
212
213fn format_timeout(timeout: std::time::Duration) -> String {
216 if timeout < std::time::Duration::from_secs(1) {
217 format!("{}ms", timeout.as_millis())
218 } else {
219 format!("{}s", timeout.as_secs())
220 }
221}
222
223fn ui_busy(error: &str) -> String {
226 format!("UI thread busy (dispatch timed out) - window state is unavailable, not empty: {error}")
227}
228
229fn tool_panicked(tool: &str, panic: &str) -> CallToolResult {
231 tool_error(format!(
232 "internal error: the '{tool}' handler panicked ({panic}); the server is still running"
233 ))
234}
235
236const MAX_INVOKE_TIMEOUT_MS: u64 = 300_000;
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242enum EvalFailureKind {
243 NotSent,
246 Aborted,
249 Page,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
255struct EvalFailure {
256 kind: EvalFailureKind,
257 message: String,
258}
259
260impl EvalFailure {
261 fn new(kind: EvalFailureKind, message: impl Into<String>) -> Self {
262 Self {
263 kind,
264 message: message.into(),
265 }
266 }
267
268 fn is_parse_error(&self) -> bool {
270 self.kind == EvalFailureKind::NotSent && self.message.starts_with(PARSE_ERROR_PREFIX)
271 }
272}
273
274const PARSE_ERROR_PREFIX: &str = "JavaScript parse error:";
276
277const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
285
286pub(crate) const MAX_CONCURRENT_PROBES: usize = 4;
289
290pub(crate) const MAX_CONCURRENT_FILE_READS: usize = 4;
293
294const READ_APP_FILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
297
298pub(crate) const COMMAND_TIMINGS_CAP: usize = 1024;
302
303const MAX_FAULT_DELAY_MS: u64 = 120_000;
305
306const FILMSTRIP_GAP: u32 = 4;
308
309const FINAL_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
311const EVAL_WINDOW_WATCH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
314
315const MIN_SEND_WINDOW: std::time::Duration = std::time::Duration::from_millis(2_500);
319
320const MIN_RESULT_WINDOW: std::time::Duration = std::time::Duration::from_secs(1);
323
324const PROBE_ANSWER_WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
327
328const WAIT_FOR_SEND_SLACK_MS: u64 = 24_000;
332const MAX_TRACE_DURATION: std::time::Duration = std::time::Duration::from_secs(30 * 60);
334const MAX_TRACE_FRAMES_RESPONSE_BYTES: usize = 25 * 1024 * 1024;
336
337const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
338const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
339const RESOURCE_URI_STATE: &str = "victauri://state";
340
341const LIST_RESULT_TTL_MS: u64 = 300_000;
347
348fn resource_required_capability(uri: &str) -> Option<(&'static str, &'static str)> {
354 match uri {
355 RESOURCE_URI_IPC_LOG => Some(("logs", "logs.ipc")),
357 RESOURCE_URI_WINDOWS => Some(("window", "window.list")),
359 RESOURCE_URI_STATE => Some(("get_plugin_info", "get_plugin_info")),
361 _ => None,
362 }
363}
364
365fn resource_allowed(privacy: &crate::privacy::PrivacyConfig, uri: &str) -> bool {
369 resource_required_capability(uri)
370 .is_none_or(|(tool, capability)| privacy.is_call_allowed(tool, capability))
371}
372
373const BRIDGE_VERSION: &str = env!("CARGO_PKG_VERSION");
374
375const SAFE_ENV_PREFIXES: &[&str] = &[
376 "HOME",
377 "USER",
378 "LANG",
379 "LC_",
380 "TERM",
381 "SHELL",
382 "DISPLAY",
383 "XDG_",
384 "TAURI_ENV_",
387 "VICTAURI_",
388 "NODE_ENV",
389 "OS",
390 "HOSTNAME",
391 "PWD",
392 "SHLVL",
393 "LOGNAME",
394];
395
396const SECRET_ENV_SUBSTRINGS: &[&str] = &[
401 "TOKEN",
402 "SECRET",
403 "PASS", "PRIVATE",
405 "CREDENTIAL",
406 "APIKEY",
407 "AUTH",
408 "_KEY",
409 "DSN", "PAT", "JWT",
412 "BEARER",
413 "SESSION",
414 "COOKIE",
415 "SALT",
416 "CERT",
417 "SIGN", "LICENSE",
419];
420
421fn is_safe_env_key(key: &str) -> bool {
424 let upper = key.to_uppercase();
425 SAFE_ENV_PREFIXES
426 .iter()
427 .any(|prefix| upper.starts_with(prefix))
428 && !SECRET_ENV_SUBSTRINGS.iter().any(|s| upper.contains(s))
429}
430
431#[derive(Clone)]
433pub struct VictauriMcpHandler {
434 state: Arc<VictauriState>,
435 bridge: Arc<dyn WebviewBridge>,
436 subscriptions: Arc<Mutex<HashSet<String>>>,
437 bridge_checked: Arc<AtomicBool>,
438 timed_out_labels: Arc<Mutex<HashSet<String>>>,
441 probe_slots: Arc<tokio::sync::Semaphore>,
443 file_slots: Arc<tokio::sync::Semaphore>,
445 probe_timeout: std::time::Duration,
447 file_read_timeout: std::time::Duration,
449}
450
451#[tool_router]
452impl VictauriMcpHandler {
453 #[tool(
456 description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.",
457 annotations(
458 read_only_hint = false,
459 destructive_hint = true,
460 idempotent_hint = false,
461 open_world_hint = false
462 )
463 )]
464 async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
465 if !self.state.privacy.is_tool_enabled("eval_js") {
466 return tool_disabled("eval_js");
467 }
468 if params.code.len() > MAX_EVAL_CODE_LEN {
469 return tool_error("code exceeds maximum length (1 MB)");
470 }
471 match self
472 .eval_with_return(¶ms.code, params.webview_label.as_deref())
473 .await
474 {
475 Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
476 Err(e) => tool_error(e),
477 }
478 }
479
480 #[tool(
481 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).",
482 annotations(
483 read_only_hint = true,
484 destructive_hint = false,
485 idempotent_hint = true,
486 open_world_hint = false
487 )
488 )]
489 async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
490 let format = params.format.unwrap_or(SnapshotFormat::Compact);
491 let format_str = match format {
492 SnapshotFormat::Compact => "compact",
493 SnapshotFormat::Json => "json",
494 };
495 let code = format!(
496 "return window.__VICTAURI__?.snapshot({})",
497 js_string(format_str)
498 );
499 self.eval_bridge(&code, params.webview_label.as_deref())
500 .await
501 }
502
503 #[tool(
504 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.",
505 annotations(
506 read_only_hint = true,
507 destructive_hint = false,
508 idempotent_hint = true,
509 open_world_hint = false
510 )
511 )]
512 async fn find_elements(
513 &self,
514 Parameters(params): Parameters<FindElementsParams>,
515 ) -> CallToolResult {
516 let mut parts: Vec<String> = Vec::new();
517 if let Some(t) = ¶ms.text {
518 parts.push(format!("text: {}", js_string(t)));
519 }
520 if let Some(r) = ¶ms.role {
521 parts.push(format!("role: {}", js_string(r)));
522 }
523 if let Some(tid) = ¶ms.test_id {
524 parts.push(format!("test_id: {}", js_string(tid)));
525 }
526 if let Some(c) = params.css.as_ref().or(params.selector.as_ref()) {
527 parts.push(format!("css: {}", js_string(c)));
528 }
529 if let Some(n) = ¶ms.name {
530 parts.push(format!("name: {}", js_string(n)));
531 }
532 if let Some(max) = params.max_results {
533 parts.push(format!("max_results: {max}"));
534 }
535 if let Some(t) = ¶ms.tag {
536 parts.push(format!("tag: {}", js_string(t)));
537 }
538 if let Some(p) = ¶ms.placeholder {
539 parts.push(format!("placeholder: {}", js_string(p)));
540 }
541 if let Some(a) = ¶ms.alt {
542 parts.push(format!("alt: {}", js_string(a)));
543 }
544 if let Some(ta) = ¶ms.title_attr {
545 parts.push(format!("title_attr: {}", js_string(ta)));
546 }
547 if let Some(l) = ¶ms.label {
548 parts.push(format!("label: {}", js_string(l)));
549 }
550 if let Some(true) = params.exact {
551 parts.push("exact: true".to_string());
552 }
553 if let Some(e) = params.enabled {
554 parts.push(format!("enabled: {e}"));
555 }
556 let code = format!(
557 "return window.__VICTAURI__?.findElements({{ {} }})",
558 parts.join(", ")
559 );
560 match self
561 .eval_with_return(&code, params.webview_label.as_deref())
562 .await
563 {
564 Ok(result) => {
565 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
566 && let Some(err) = parsed.get("error").and_then(|e| e.as_str())
567 {
568 return tool_error(err);
569 }
570 CallToolResult::success(vec![ContentBlock::text(result)])
571 }
572 Err(e) => tool_error(e),
573 }
574 }
575
576 #[tool(
577 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 — or, with `with_logs: true`, `{result, backend_logs}`: the Rust log entries captured while it ran. Waits up to the eval timeout (30s) — pass `timeout_ms` (max 300000) for a legitimately slow command. Subject to privacy command filtering.",
578 annotations(
579 read_only_hint = false,
580 destructive_hint = true,
581 idempotent_hint = false,
582 open_world_hint = false
583 )
584 )]
585 async fn invoke_command(
586 &self,
587 Parameters(params): Parameters<InvokeCommandParams>,
588 ) -> CallToolResult {
589 if !self.state.privacy.is_invoke_allowed(¶ms.command) {
590 return tool_disabled("invoke_command");
591 }
592 if !self.state.privacy.is_command_allowed(¶ms.command) {
593 return tool_error(format!(
594 "command '{}' is blocked by privacy configuration",
595 params.command
596 ));
597 }
598
599 if let Some(fault) = self.state.fault_registry.check_and_trigger(¶ms.command) {
601 match fault {
602 crate::introspection::FaultType::Delay { delay_ms } => {
603 tracing::info!(
604 command = %params.command,
605 delay_ms = delay_ms,
606 "fault injection: delaying command"
607 );
608 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
609 }
611 crate::introspection::FaultType::Error { ref message } => {
612 tracing::info!(
613 command = %params.command,
614 "fault injection: returning error"
615 );
616 return tool_error(format!(
617 "[FAULT INJECTED] command '{}': {message}",
618 params.command
619 ));
620 }
621 crate::introspection::FaultType::Drop => {
622 tracing::info!(
623 command = %params.command,
624 "fault injection: dropping response"
625 );
626 return CallToolResult::success(vec![ContentBlock::text("{}")]);
627 }
628 crate::introspection::FaultType::Corrupt => {
629 tracing::info!(
630 command = %params.command,
631 "fault injection: corrupting response"
632 );
633 let args_json = params.args.unwrap_or(serde_json::json!({}));
635 let args_str =
636 serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
637 let code = format!(
638 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
639 js_string(¶ms.command)
640 );
641 if let Ok(result) = self
642 .eval_with_return(&code, params.webview_label.as_deref())
643 .await
644 {
645 let corrupted = format!(
646 "{{\"__corrupted\":true,\"original_length\":{},\"fault\":\"corrupt\"}}",
647 result.len()
648 );
649 return CallToolResult::success(vec![ContentBlock::text(corrupted)]);
650 }
651 return CallToolResult::success(vec![ContentBlock::text(
652 "{\"__corrupted\":true,\"fault\":\"corrupt\",\"note\":\"original invocation also failed\"}",
653 )]);
654 }
655 }
656 }
657
658 let with_logs = params.with_logs.unwrap_or(false);
662 if with_logs && !self.state.privacy.is_call_allowed("logs", "logs.backend") {
663 return tool_disabled("logs.backend");
664 }
665 let log_cursor = with_logs.then(|| self.backend_log_cursor());
666 let fail = |message: String| match log_cursor {
667 Some(cursor) => self.backend_logs_error(&message, cursor),
668 None => tool_error(message),
669 };
670 let start = std::time::Instant::now();
671 let args_json = params.args.unwrap_or(serde_json::json!({}));
672 let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
673 let code = format!(
674 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
675 js_string(¶ms.command)
676 );
677 let timeout = params.timeout_ms.map_or(self.state.eval_timeout, |ms| {
678 std::time::Duration::from_millis(ms.clamp(1, MAX_INVOKE_TIMEOUT_MS))
679 });
680 let result = self
681 .eval_outcome(&code, params.webview_label.as_deref(), timeout)
682 .await;
683 let elapsed = start.elapsed();
684 let ran = result
688 .as_ref()
689 .map_or_else(|f| f.kind == EvalFailureKind::Page, |_| true);
690 if ran {
691 self.state.command_timings.record(¶ms.command, elapsed);
692 }
693 match result {
694 Ok(result) => {
695 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
696 && let Some(err) = parsed.get("__error").and_then(|e| e.as_str())
697 {
698 return fail(format!(
699 "command '{}' returned error: {err}",
700 params.command
701 ));
702 }
703 if let Some(cursor) = log_cursor {
704 return self.attach_backend_logs(&result, cursor);
705 }
706 CallToolResult::success(vec![ContentBlock::text(result)])
707 }
708 Err(f)
711 if f.kind == EvalFailureKind::Aborted
712 && f.message.starts_with("eval timed out") =>
713 {
714 fail(format!(
715 "invoke_command failed: {} If the command is legitimately slow, pass \
716 `timeout_ms` (up to {MAX_INVOKE_TIMEOUT_MS}).",
717 f.message
718 ))
719 }
720 Err(f) => fail(format!("invoke_command failed: {}", f.message)),
721 }
722 }
723
724 #[tool(
725 description = "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Works on Windows (PrintWindow), macOS (CGWindowListCreateImage), and Linux X11/XWayland. Pure Wayland fails safely because its available fallback would capture the full desktop rather than the requested window. A hidden (non-visible) window has no on-screen surface to capture, so requesting one returns a clear error (show it first via `window` manage_action=show) rather than a stale or wrong-window image.",
726 annotations(
727 read_only_hint = true,
728 destructive_hint = false,
729 idempotent_hint = true,
730 open_world_hint = false
731 )
732 )]
733 async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
734 if !self.state.privacy.is_tool_enabled("screenshot") {
735 return tool_disabled("screenshot");
736 }
737 let target_label = match self.resolve_visible_capture_target(params.window_label.as_deref())
740 {
741 Ok(label) => label,
742 Err(e) => return tool_error(e),
743 };
744 match self.bridge.get_native_handle(Some(&target_label)) {
745 Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
746 Ok(png_bytes) => {
747 use base64::Engine;
748 let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
749 CallToolResult::success(vec![ContentBlock::image(b64, "image/png")])
750 }
751 Err(e) => tool_error(format!("screenshot capture failed: {e}")),
752 },
753 Err(e) => tool_error(format!("cannot get window handle: {e}")),
754 }
755 }
756
757 #[tool(
758 description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.",
759 annotations(
760 read_only_hint = false,
761 destructive_hint = true,
762 idempotent_hint = false,
763 open_world_hint = false
764 )
765 )]
766 async fn verify_state(
767 &self,
768 Parameters(params): Parameters<VerifyStateParams>,
769 ) -> CallToolResult {
770 if !self.state.privacy.is_tool_enabled("eval_js") {
771 return tool_disabled("verify_state requires eval_js capability");
772 }
773 let code = expression_eval_code(¶ms.frontend_expr);
774 let frontend_json = match self
775 .eval_with_return(&code, params.webview_label.as_deref())
776 .await
777 {
778 Ok(result) => result,
779 Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
780 };
781
782 let frontend_state: serde_json::Value = match parse_expression_value(&frontend_json) {
783 Ok(v) => v,
784 Err(e) => {
785 return tool_error(format!(
786 "frontend expression did not return valid JSON: {e}"
787 ));
788 }
789 };
790
791 let backend_state = if let Some(state) = params.backend_state {
792 state
793 } else if let Some(ref cmd) = params.backend_command {
794 if !self.state.privacy.is_invoke_allowed(cmd)
798 || !self.state.privacy.is_command_allowed(cmd)
799 {
800 return tool_error(format!(
801 "command '{cmd}' is blocked by privacy configuration"
802 ));
803 }
804 let args = params.backend_args.unwrap_or(serde_json::json!({}));
805 let args_str = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
806 let invoke_code = format!(
807 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
808 js_string(cmd)
809 );
810 match self
811 .eval_with_return(&invoke_code, params.webview_label.as_deref())
812 .await
813 {
814 Ok(result) => match serde_json::from_str(&result) {
815 Ok(v) => v,
816 Err(e) => {
817 return tool_error(format!(
818 "backend command '{cmd}' did not return valid JSON: {e}"
819 ));
820 }
821 },
822 Err(e) => {
823 return tool_error(format!("failed to invoke backend command '{cmd}': {e}"));
824 }
825 }
826 } else {
827 return tool_error("either backend_state or backend_command must be provided");
828 };
829
830 let result = victauri_core::verify_state(frontend_state, backend_state);
831 json_result(&result)
832 }
833
834 #[tool(
835 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. `ipc_capture` reports how complete the underlying IPC picture is (a `capture` other than `full` means calls may be missing).",
836 annotations(
837 read_only_hint = true,
838 destructive_hint = false,
839 idempotent_hint = true,
840 open_world_hint = false
841 )
842 )]
843 async fn detect_ghost_commands(
844 &self,
845 Parameters(params): Parameters<GhostCommandParams>,
846 ) -> CallToolResult {
847 let code = ghost_ipc_outcomes_js(params.since_ms);
853 let ipc_json = match self
854 .eval_with_return(&code, params.webview_label.as_deref())
855 .await
856 {
857 Ok(r) => r,
858 Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
859 };
860
861 let outcomes: Vec<crate::mcp::helpers::IpcOutcome> = match serde_json::from_str(&ipc_json) {
862 Ok(v) => v,
863 Err(e) => return tool_error(format!("failed to parse IPC log JSON: {e}")),
864 };
865
866 let mut report = build_ghost_report(&outcomes, &self.state.registry);
867 if let Some(obj) = report.as_object_mut() {
868 obj.insert(
869 "ipc_capture".to_string(),
870 self.ipc_capture_status(params.webview_label.as_deref())
871 .await,
872 );
873 }
874 json_result(&report)
875 }
876
877 #[tool(
878 description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.",
879 annotations(
880 read_only_hint = true,
881 destructive_hint = false,
882 idempotent_hint = true,
883 open_world_hint = false
884 )
885 )]
886 async fn check_ipc_integrity(
887 &self,
888 Parameters(params): Parameters<IpcIntegrityParams>,
889 ) -> CallToolResult {
890 let threshold = params.stale_threshold_ms.unwrap_or(5000);
891 let code = ipc_integrity_js(threshold);
892 self.eval_bridge(&code, params.webview_label.as_deref())
893 .await
894 }
895
896 #[tool(
897 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), log (block until a backend Rust log entry containing `value` is captured, optional min `level`, `fields` to also match structured fields e.g. {\"run_type\":\"foreground\"} so a concurrent run of the same code path cannot satisfy it, `since_ms` look-back — wakes the instant it is logged). Use expression/event/log to await async backend work to true completion instead of guessing with a fixed sleep.",
898 annotations(
899 read_only_hint = false,
900 destructive_hint = true,
901 idempotent_hint = false,
902 open_world_hint = false
903 )
904 )]
905 async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
906 let timeout_ms = params.timeout_ms.unwrap_or(10_000).min(120_000);
907 let poll = params.poll_ms.unwrap_or(200).clamp(20, timeout_ms.max(20));
910
911 match params.condition {
915 WaitCondition::Expression => {
916 return self.wait_for_expression(¶ms, timeout_ms, poll).await;
917 }
918 WaitCondition::Event => {
919 return self.wait_for_event(¶ms, timeout_ms, poll).await;
920 }
921 WaitCondition::Log => {
922 return self.wait_for_log(¶ms, timeout_ms, poll).await;
923 }
924 _ => {}
925 }
926
927 let value = params
928 .value
929 .as_ref()
930 .map_or_else(|| "null".to_string(), |v| js_string(v));
931 let code = format!(
932 "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
933 js_string(params.condition.as_str())
934 );
935 let eval_timeout = std::time::Duration::from_millis(timeout_ms + WAIT_FOR_SEND_SLACK_MS);
941 match self
942 .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
943 .await
944 {
945 Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
946 Err(e) => tool_error(e),
947 }
948 }
949
950 async fn wait_for_expression(
957 &self,
958 params: &WaitForParams,
959 timeout_ms: u64,
960 poll_ms: u64,
961 ) -> CallToolResult {
962 if !self.state.privacy.is_tool_enabled("eval_js") {
963 return tool_disabled("wait_for(expression) requires eval_js capability");
964 }
965 let Some(expr) = params.value.as_deref().filter(|s| !s.is_empty()) else {
966 return missing_param("value", "wait_for(expression)");
967 };
968 let code = expression_eval_code(expr);
969 let start = std::time::Instant::now();
970 let deadline = start + std::time::Duration::from_millis(timeout_ms);
971 let poll = std::time::Duration::from_millis(poll_ms);
972 let mut last_value = serde_json::Value::Null;
973 let mut last_error: Option<String> = None;
974
975 loop {
976 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
977 let per_eval = remaining
978 .min(std::time::Duration::from_secs(15))
979 .max(std::time::Duration::from_secs(1));
980 match self
981 .eval_outcome(&code, params.webview_label.as_deref(), per_eval)
982 .await
983 {
984 Err(f) if f.is_parse_error() => {
987 return tool_error(format!("wait_for(expression): {}", f.message));
988 }
989 Ok(raw) => {
990 let val = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null);
991 let met = match ¶ms.expected {
992 Some(expected) => &val == expected,
993 None => json_truthy(&val),
994 };
995 if met {
996 return json_result(&serde_json::json!({
997 "ok": true,
998 "value": val,
999 "elapsed_ms": start.elapsed().as_millis() as u64,
1000 }));
1001 }
1002 last_value = val;
1003 }
1004 Err(e) => last_error = Some(e.message),
1005 }
1006
1007 if std::time::Instant::now() >= deadline {
1008 return json_result(&serde_json::json!({
1009 "ok": false,
1010 "error": format!("timeout after {timeout_ms}ms"),
1011 "last_value": last_value,
1012 "last_error": last_error,
1013 "elapsed_ms": start.elapsed().as_millis() as u64,
1014 }));
1015 }
1016 tokio::time::sleep(
1017 poll.min(deadline.saturating_duration_since(std::time::Instant::now())),
1018 )
1019 .await;
1020 }
1021 }
1022
1023 async fn wait_for_event(
1030 &self,
1031 params: &WaitForParams,
1032 timeout_ms: u64,
1033 poll_ms: u64,
1034 ) -> CallToolResult {
1035 let Some(name) = params.value.as_deref().filter(|s| !s.is_empty()) else {
1036 return missing_param("value", "wait_for(event)");
1037 };
1038 let since_ms = params.since_ms.unwrap_or(2000);
1039 let start = std::time::Instant::now();
1040 let baseline = bounded::ms_ago(chrono::Utc::now(), since_ms);
1041 let deadline = start + std::time::Duration::from_millis(timeout_ms);
1042 let poll = std::time::Duration::from_millis(poll_ms);
1043
1044 loop {
1045 let matched = self.state.event_bus.events().into_iter().rev().find(|e| {
1047 e.name == name
1048 && chrono::DateTime::parse_from_rfc3339(&e.timestamp)
1049 .map_or(true, |ts| ts.with_timezone(&chrono::Utc) >= baseline)
1050 });
1051 if let Some(ev) = matched {
1052 return json_result(&serde_json::json!({
1053 "ok": true,
1054 "event": {
1055 "name": ev.name,
1056 "payload": ev.payload,
1057 "timestamp": ev.timestamp,
1058 },
1059 "elapsed_ms": start.elapsed().as_millis() as u64,
1060 }));
1061 }
1062 if std::time::Instant::now() >= deadline {
1063 return json_result(&serde_json::json!({
1064 "ok": false,
1065 "error": format!("timeout after {timeout_ms}ms waiting for event '{name}'"),
1066 "hint": "Ensure the app emits this Tauri event and Victauri captures it: \
1067 custom events need VictauriBuilder::listen_events(&[\"…\"]); \
1068 window-lifecycle events are captured automatically.",
1069 "elapsed_ms": start.elapsed().as_millis() as u64,
1070 }));
1071 }
1072 tokio::time::sleep(
1074 poll.min(deadline.saturating_duration_since(std::time::Instant::now())),
1075 )
1076 .await;
1077 }
1078 }
1079
1080 #[tool(
1081 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. A result of `undefined` is treated as `null` (JSON has no undefined; like JS `x == null`): `exists` is false, `falsy` is true, `equals null` and `type_is \"null\"` pass.",
1082 annotations(
1083 read_only_hint = false,
1084 destructive_hint = true,
1085 idempotent_hint = false,
1086 open_world_hint = false
1087 )
1088 )]
1089 async fn assert_semantic(
1090 &self,
1091 Parameters(params): Parameters<SemanticAssertParams>,
1092 ) -> CallToolResult {
1093 if !self.state.privacy.is_tool_enabled("eval_js") {
1094 return tool_disabled("assert_semantic requires eval_js capability");
1095 }
1096 let code = expression_eval_code(¶ms.expression);
1097 let actual_json = match self
1098 .eval_with_return(&code, params.webview_label.as_deref())
1099 .await
1100 {
1101 Ok(result) => result,
1102 Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
1103 };
1104
1105 let actual: serde_json::Value = match parse_expression_value(&actual_json) {
1106 Ok(v) => v,
1107 Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
1108 };
1109
1110 let assertion =
1111 victauri_core::SemanticAssertion::new(params.label, params.condition, params.expected);
1112
1113 let result = victauri_core::evaluate_assertion(actual, &assertion);
1114 json_result(&result)
1115 }
1116
1117 #[tool(
1118 description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.",
1119 annotations(
1120 read_only_hint = true,
1121 destructive_hint = false,
1122 idempotent_hint = true,
1123 open_world_hint = false
1124 )
1125 )]
1126 async fn resolve_command(
1127 &self,
1128 Parameters(params): Parameters<ResolveCommandParams>,
1129 ) -> CallToolResult {
1130 let limit = params.limit.unwrap_or(5);
1131 let mut results = self.state.registry.resolve(¶ms.query);
1132 results.truncate(limit);
1133 json_result(&results)
1134 }
1135
1136 #[tool(
1137 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.",
1138 annotations(
1139 read_only_hint = true,
1140 destructive_hint = false,
1141 idempotent_hint = true,
1142 open_world_hint = false
1143 )
1144 )]
1145 async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
1146 let commands = match params.query {
1147 Some(q) => self.state.registry.search(&q),
1148 None => self.state.registry.list(),
1149 };
1150 json_result(&commands)
1151 }
1152
1153 #[tool(
1154 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).",
1155 annotations(
1156 read_only_hint = true,
1157 destructive_hint = false,
1158 idempotent_hint = true,
1159 open_world_hint = false
1160 )
1161 )]
1162 async fn app_state(&self, Parameters(params): Parameters<AppStateParams>) -> CallToolResult {
1163 let Some(name) = params.probe else {
1164 return json_result(&serde_json::json!({ "probes": self.state.probes.names() }));
1165 };
1166 if let Some(probe) = self.state.probes.get(&name) {
1167 let Ok(slot) = Arc::clone(&self.probe_slots).try_acquire_owned() else {
1172 return tool_error(format!(
1173 "probe '{name}' not run: app probes are busy ({MAX_CONCURRENT_PROBES} \
1174 still running — a probe that hangs keeps running past its timeout). \
1175 Retry shortly."
1176 ));
1177 };
1178 match bounded::run_blocking_bounded(
1179 None,
1180 &format!("probe '{name}'"),
1181 self.probe_timeout,
1182 move || {
1183 let _slot = slot;
1184 Ok(probe())
1185 },
1186 )
1187 .await
1188 {
1189 Ok(value) => json_result(&value),
1190 Err(e) => tool_error(e),
1191 }
1192 } else {
1193 let available = self.state.probes.names();
1194 tool_error_with_hint(
1195 format!(
1196 "unknown probe '{name}'. Available probes: {}",
1197 if available.is_empty() {
1198 "(none registered — add VictauriBuilder::probe(\"name\", ...))".to_string()
1199 } else {
1200 available.join(", ")
1201 }
1202 ),
1203 RecoveryHint::CheckInput,
1204 )
1205 }
1206 }
1207
1208 #[tool(
1209 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.",
1210 annotations(
1211 read_only_hint = true,
1212 destructive_hint = false,
1213 idempotent_hint = true,
1214 open_world_hint = false
1215 )
1216 )]
1217 async fn get_memory_stats(&self) -> CallToolResult {
1218 let stats = crate::memory::current_stats();
1219 json_result(&stats)
1220 }
1221
1222 #[tool(
1223 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.",
1224 annotations(
1225 read_only_hint = true,
1226 destructive_hint = false,
1227 idempotent_hint = true,
1228 open_world_hint = false
1229 )
1230 )]
1231 async fn get_plugin_info(&self) -> CallToolResult {
1232 let disabled: Vec<&str> = self
1233 .state
1234 .privacy
1235 .disabled_tools
1236 .iter()
1237 .map(std::string::String::as_str)
1238 .collect();
1239 let blocklist: Vec<&str> = self
1240 .state
1241 .privacy
1242 .command_blocklist
1243 .iter()
1244 .map(std::string::String::as_str)
1245 .collect();
1246 let allowlist: Option<Vec<&str>> = self
1247 .state
1248 .privacy
1249 .command_allowlist
1250 .as_ref()
1251 .map(|s| s.iter().map(std::string::String::as_str).collect());
1252 let all_tools = Self::tool_router().list_all();
1253 let enabled_tools: Vec<&str> = all_tools
1254 .iter()
1255 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
1256 .map(|t| t.name.as_ref())
1257 .collect();
1258
1259 let app_cfg = self.bridge.tauri_config();
1262 let result = serde_json::json!({
1263 "version": env!("CARGO_PKG_VERSION"),
1264 "bridge_version": BRIDGE_VERSION,
1265 "port": self.state.port.load(Ordering::Relaxed),
1266 "app": {
1267 "identifier": app_cfg.get("identifier"),
1268 "product_name": app_cfg.get("product_name"),
1269 },
1270 "tools": {
1271 "total": all_tools.len(),
1272 "enabled": enabled_tools.len(),
1273 "enabled_list": enabled_tools,
1274 "disabled_list": disabled,
1275 },
1276 "commands": {
1277 "allowlist": allowlist,
1278 "blocklist": blocklist,
1279 },
1280 "privacy": {
1281 "profile": self.state.privacy.profile.to_string(),
1282 "redaction_enabled": self.state.privacy.redaction_enabled,
1283 },
1284 "capacities": {
1285 "event_log": self.state.event_log.capacity(),
1286 "eval_timeout_secs": self.state.eval_timeout.as_secs(),
1287 },
1288 "registered_commands": self.state.registry.count(),
1289 "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
1290 "uptime_secs": self.state.started_at.elapsed().as_secs(),
1291 });
1292 json_result(&result)
1293 }
1294
1295 #[tool(
1296 description = "Run environment diagnostics: IPC capture completeness (`info.ipc_capture`: the Tauri IPC transport in use — fetch, or the postMessage fallback Tauri switches to after a custom-protocol failure — and whether capture is full, requests-only or none), 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.",
1297 annotations(
1298 read_only_hint = true,
1299 destructive_hint = false,
1300 idempotent_hint = true,
1301 open_world_hint = false
1302 )
1303 )]
1304 async fn get_diagnostics(
1305 &self,
1306 Parameters(params): Parameters<DiagnosticsParams>,
1307 ) -> CallToolResult {
1308 self.eval_bridge(
1309 "return window.__VICTAURI__?.getDiagnostics()",
1310 params.webview_label.as_deref(),
1311 )
1312 .await
1313 }
1314
1315 #[tool(
1318 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.",
1319 annotations(
1320 read_only_hint = true,
1321 destructive_hint = false,
1322 idempotent_hint = true,
1323 open_world_hint = false
1324 )
1325 )]
1326 async fn app_info(&self) -> CallToolResult {
1327 let config = self.bridge.tauri_config();
1328
1329 let data_dir = self.bridge.app_data_dir().ok();
1330 let config_dir = self.bridge.app_config_dir().ok();
1331 let log_dir = self.bridge.app_log_dir().ok();
1332 let local_data_dir = self.bridge.app_local_data_dir().ok();
1333
1334 let env_vars: std::collections::BTreeMap<String, String> = std::env::vars()
1335 .filter(|(k, _)| is_safe_env_key(k))
1336 .collect();
1337
1338 #[cfg(feature = "sqlite")]
1345 let databases: Vec<serde_json::Value> = {
1346 let mut all_dirs: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
1347 for d in [
1348 data_dir.as_ref(),
1349 config_dir.as_ref(),
1350 log_dir.as_ref(),
1351 local_data_dir.as_ref(),
1352 ]
1353 .into_iter()
1354 .flatten()
1355 {
1356 all_dirs.push(d.clone());
1357 }
1358 let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
1359 all_dirs.clone()
1360 } else {
1361 self.state.db_search_paths.clone()
1362 };
1363 tokio::task::spawn_blocking(move || {
1366 let selected = crate::database::select_app_database(&select_dirs).ok();
1367 crate::database::classify_databases(&all_dirs)
1368 .into_iter()
1369 .map(|c| {
1370 serde_json::json!({
1371 "path": c.path.to_string_lossy(),
1372 "size_bytes": c.size_bytes,
1373 "webview_internal": c.webview_internal,
1374 "selected": selected.as_ref() == Some(&c.path),
1375 })
1376 })
1377 .collect()
1378 })
1379 .await
1380 .unwrap_or_default()
1381 };
1382
1383 #[cfg(not(feature = "sqlite"))]
1384 let databases: Vec<serde_json::Value> = Vec::new();
1385
1386 let result = serde_json::json!({
1387 "config": config,
1388 "paths": {
1389 "data": data_dir.as_ref().map(|p| p.to_string_lossy()),
1390 "config": config_dir.as_ref().map(|p| p.to_string_lossy()),
1391 "log": log_dir.as_ref().map(|p| p.to_string_lossy()),
1392 "local_data": local_data_dir.as_ref().map(|p| p.to_string_lossy()),
1393 },
1394 "databases": databases,
1395 "env": env_vars,
1396 "process": {
1397 "pid": std::process::id(),
1398 "arch": std::env::consts::ARCH,
1399 "os": std::env::consts::OS,
1400 "family": std::env::consts::FAMILY,
1401 },
1402 });
1403 json_result(&result)
1404 }
1405
1406 #[tool(
1407 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.",
1408 annotations(
1409 read_only_hint = true,
1410 destructive_hint = false,
1411 idempotent_hint = true,
1412 open_world_hint = false
1413 )
1414 )]
1415 async fn list_app_dir(
1416 &self,
1417 Parameters(params): Parameters<ListAppDirParams>,
1418 ) -> CallToolResult {
1419 let base = match self.resolve_app_dir(params.directory) {
1420 Ok(d) => d,
1421 Err(e) => return tool_error(e),
1422 };
1423
1424 if let Some(sub) = params.path.as_deref()
1427 && let Err(e) = Self::lexical_safe(std::path::Path::new(sub))
1428 {
1429 return tool_error(e);
1430 }
1431 let walk = tokio::task::spawn_blocking(move || Self::list_app_dir_blocking(&base, ¶ms));
1434 match walk.await {
1435 Ok(Ok(listing)) => json_result(&listing),
1436 Ok(Err(e)) => tool_error(e),
1437 Err(e) => tool_error(format!("directory listing task failed: {e}")),
1438 }
1439 }
1440
1441 fn list_app_dir_blocking(
1442 base: &std::path::Path,
1443 params: &ListAppDirParams,
1444 ) -> Result<serde_json::Value, String> {
1445 let sub = params.path.clone().unwrap_or_default();
1446 let target = base.join(&sub);
1447 if !Self::contained_or_missing(base, &target)? {
1451 return Ok(serde_json::json!({
1452 "base": base.to_string_lossy(),
1453 "path": sub,
1454 "exists": false,
1455 "entries": [],
1456 "count": 0,
1457 }));
1458 }
1459 let canon_base = std::fs::canonicalize(base)
1460 .map_err(|e| format!("cannot resolve base directory: {e}"))?;
1461
1462 let mut walk = DirWalk {
1463 canon_base,
1464 pattern: params.pattern.clone(),
1465 max_depth: params.max_depth.unwrap_or(1).min(5),
1466 deadline: std::time::Instant::now() + MAX_DIR_WALK_TIME,
1467 max_visited: MAX_DIR_VISITED,
1468 visited: 0,
1469 truncated: false,
1470 entries: Vec::new(),
1471 };
1472 walk.visit(&target, base, 0);
1473
1474 Ok(serde_json::json!({
1475 "base": base.to_string_lossy(),
1476 "path": sub,
1477 "exists": true,
1478 "count": walk.entries.len(),
1479 "entries": walk.entries,
1480 "truncated": walk.truncated,
1481 }))
1482 }
1483
1484 #[tool(
1485 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.",
1486 annotations(
1487 read_only_hint = true,
1488 destructive_hint = false,
1489 idempotent_hint = true,
1490 open_world_hint = false
1491 )
1492 )]
1493 async fn read_app_file(
1494 &self,
1495 Parameters(params): Parameters<ReadAppFileParams>,
1496 ) -> CallToolResult {
1497 let base = match self.resolve_app_dir(params.directory) {
1498 Ok(d) => d,
1499 Err(e) => return tool_error(e),
1500 };
1501
1502 if let Err(e) = Self::lexical_safe(std::path::Path::new(¶ms.path)) {
1508 return tool_error(e);
1509 }
1510 let target = base.join(¶ms.path);
1511 match Self::contained_or_missing(&base, &target) {
1513 Ok(true) => {}
1514 Ok(false) => return tool_error(format!("file not found: {}", params.path)),
1515 Err(e) => return tool_error(e),
1516 }
1517 if let Err(e) = Self::safe_within(&base, &target) {
1518 return tool_error(e);
1519 }
1520 if !target.is_file() {
1521 return tool_error(format!("not a file: {}", params.path));
1522 }
1523
1524 let max_bytes = params.max_bytes.unwrap_or(1_048_576).min(10_485_760);
1525
1526 let canonical = match std::fs::canonicalize(&target) {
1532 Ok(c) => c,
1533 Err(e) => return tool_error(format!("cannot resolve path: {e}")),
1534 };
1535 let (mut bytes, original_size, modified) =
1536 match self.read_regular_file_bounded(canonical, max_bytes).await {
1537 Ok(v) => v,
1538 Err(e) => return tool_error(format!("failed to read file: {e}")),
1539 };
1540 let truncated = bytes.len() > max_bytes;
1541 if truncated {
1542 bytes.truncate(max_bytes);
1543 }
1544
1545 let file_info = serde_json::json!({
1546 "path": params.path,
1547 "size": original_size,
1548 "truncated": truncated,
1549 "modified": modified,
1550 });
1551
1552 if params.binary == Some(true) {
1553 use base64::Engine;
1554 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1555 json_result(&serde_json::json!({
1556 "file": file_info,
1557 "encoding": "base64",
1558 "content": b64,
1559 }))
1560 } else {
1561 if truncated
1565 && let Err(e) = std::str::from_utf8(&bytes)
1566 && e.error_len().is_none()
1567 {
1568 bytes.truncate(e.valid_up_to());
1569 }
1570 match String::from_utf8(bytes) {
1571 Ok(text) => json_result(&serde_json::json!({
1572 "file": file_info,
1573 "encoding": "utf-8",
1574 "content": text,
1575 })),
1576 Err(e) => {
1577 use base64::Engine;
1578 let bytes = e.into_bytes();
1579 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1580 json_result(&serde_json::json!({
1581 "file": file_info,
1582 "encoding": "base64",
1583 "note": "file is not valid UTF-8, returning base64",
1584 "content": b64,
1585 }))
1586 }
1587 }
1588 }
1589 }
1590
1591 #[tool(
1592 description = "Execute a bounded, read-only SQL query against a SQLite database in the app's data directory. The SQL goes in the `query` field (alias: `sql`). Auto-discovers database files if no path is specified. Only SELECT/PRAGMA/EXPLAIN/WITH queries are allowed. CPU time, cell size, row count, and returned bytes are capped. Returns rows as JSON objects with column names as keys. This provides direct backend database access without going through the webview or IPC.",
1593 annotations(
1594 read_only_hint = true,
1595 destructive_hint = false,
1596 idempotent_hint = true,
1597 open_world_hint = false
1598 )
1599 )]
1600 async fn query_db(&self, Parameters(params): Parameters<QueryDbParams>) -> CallToolResult {
1601 #[cfg(feature = "sqlite")]
1606 {
1607 self.query_db_impl(params).await
1608 }
1609 #[cfg(not(feature = "sqlite"))]
1610 {
1611 let _ = params;
1612 tool_error(
1613 "query_db is unavailable: this build was compiled without the 'sqlite' \
1614 feature (default-features = false). Re-enable the 'sqlite' feature to use it.",
1615 )
1616 }
1617 }
1618
1619 #[cfg(feature = "sqlite")]
1621 async fn query_db_impl(&self, params: QueryDbParams) -> CallToolResult {
1622 if let Err(e) = crate::database::validate_query(¶ms.query) {
1626 return tool_error(e);
1627 }
1628 let data_dir = match self.bridge.app_data_dir() {
1629 Ok(d) => d,
1630 Err(e) => return tool_error(format!("cannot access app data directory: {e}")),
1631 };
1632
1633 let search_dirs = self.db_roots();
1634
1635 let db_path = if let Some(ref requested_path) = params.path {
1636 match Self::resolve_existing_db_path(&search_dirs, requested_path) {
1637 Ok(path) => path,
1638 Err(e) => return tool_error(e),
1639 }
1640 } else {
1641 let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
1648 search_dirs.clone()
1649 } else {
1650 self.state.db_search_paths.clone()
1651 };
1652 match tokio::task::spawn_blocking(move || {
1654 crate::database::select_app_database(&select_dirs)
1655 })
1656 .await
1657 {
1658 Ok(Ok(p)) => p,
1659 Ok(Err(e)) => return tool_error(e),
1660 Err(e) => return tool_error(format!("database discovery failed: {e}")),
1661 }
1662 };
1663
1664 let db_display = db_path
1665 .strip_prefix(&data_dir)
1666 .unwrap_or(&db_path)
1667 .to_string_lossy()
1668 .into_owned();
1669 let bind_params = params.params.unwrap_or_default();
1670 let query = params.query;
1671 let max_rows = params.max_rows;
1672
1673 match bounded::run_blocking_bounded(
1674 Some(&bounded::DB_SLOTS),
1675 "database query",
1676 crate::database::QUERY_TIMEOUT + bounded::BLOCKING_DEADLINE_SLACK,
1677 move || crate::database::query(&db_path, &query, &bind_params, max_rows),
1678 )
1679 .await
1680 {
1681 Ok(mut result) => {
1682 if let Some(obj) = result.as_object_mut() {
1683 obj.insert("database".to_string(), serde_json::json!(db_display));
1684 }
1685 json_result(&result)
1686 }
1687 Err(e) => tool_error(e),
1688 }
1689 }
1690
1691 #[cfg(feature = "sqlite")]
1698 fn db_roots(&self) -> Vec<std::path::PathBuf> {
1699 let mut roots: Vec<std::path::PathBuf> = Vec::new();
1700 let app_dirs = [
1701 self.bridge.app_data_dir(),
1702 self.bridge.app_config_dir(),
1703 self.bridge.app_local_data_dir(),
1704 self.bridge.app_log_dir(),
1705 ];
1706 let candidates = self
1707 .state
1708 .db_search_paths
1709 .iter()
1710 .cloned()
1711 .chain(app_dirs.into_iter().filter_map(Result::ok));
1712 for dir in candidates {
1713 if !roots.contains(&dir) {
1714 roots.push(dir);
1715 }
1716 }
1717 roots
1718 }
1719
1720 #[tool(
1723 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. An action the page refuses (element covered, disabled, hidden, detached, or ref not found) returns an error with the reason and a [hint: RETRY_LATER|CHECK_INPUT].",
1724 annotations(
1725 read_only_hint = false,
1726 destructive_hint = false,
1727 idempotent_hint = false,
1728 open_world_hint = false
1729 )
1730 )]
1731 async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
1732 if !self.state.privacy.is_tool_enabled("interact") {
1733 return tool_disabled("interact");
1734 }
1735 match params.action {
1736 InteractAction::Click => {
1737 if !self.state.privacy.is_tool_enabled("interact.click") {
1738 return tool_disabled("interact.click");
1739 }
1740 let Some(ref_id) = ¶ms.ref_id else {
1741 return missing_param("ref_id", "click");
1742 };
1743 if params.trusted.unwrap_or(false) {
1744 let probe = trusted_click_probe_js(ref_id);
1748 let raw = match self
1749 .eval_with_return(&probe, params.webview_label.as_deref())
1750 .await
1751 {
1752 Ok(r) => r,
1753 Err(e) => return tool_error(e),
1754 };
1755 let point = serde_json::from_str::<serde_json::Value>(&raw).unwrap_or_default();
1756 if let Some(why) = point.get("error").and_then(serde_json::Value::as_str) {
1757 return tool_error_with_hint(
1758 format!(
1759 "trusted click on {ref_id} refused: {why} — no OS click was sent"
1760 ),
1761 RecoveryHint::CheckInput,
1762 );
1763 }
1764 let (Some(x), Some(y)) = (
1765 point.get("x").and_then(serde_json::Value::as_f64),
1766 point.get("y").and_then(serde_json::Value::as_f64),
1767 ) else {
1768 return tool_error_with_hint(
1769 format!("ref not found: {ref_id}"),
1770 RecoveryHint::CheckInput,
1771 );
1772 };
1773 if !(x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0) {
1774 return tool_error_with_hint(
1775 format!(
1776 "trusted click on {ref_id} refused: the page reported an \
1777 unusable click point ({x}, {y}) — no OS click was sent"
1778 ),
1779 RecoveryHint::CheckInput,
1780 );
1781 }
1782 let bridge = self.bridge.clone();
1783 let label = params.webview_label.clone();
1784 let native = tokio::task::spawn_blocking(move || {
1785 bridge.native_click(label.as_deref(), x, y)
1786 })
1787 .await
1788 .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1789 return match native {
1790 Ok(()) => json_result(
1791 &serde_json::json!({"ok": true, "trusted": true, "x": x, "y": y}),
1792 ),
1793 Err(e) => tool_error(e),
1794 };
1795 }
1796 let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
1797 self.eval_page_action(&code, params.webview_label.as_deref())
1798 .await
1799 }
1800 InteractAction::DoubleClick => {
1801 if !self.state.privacy.is_tool_enabled("interact.double_click") {
1802 return tool_disabled("interact.double_click");
1803 }
1804 let Some(ref_id) = ¶ms.ref_id else {
1805 return missing_param("ref_id", "double_click");
1806 };
1807 let code = format!(
1808 "return window.__VICTAURI__?.doubleClick({})",
1809 js_string(ref_id)
1810 );
1811 self.eval_page_action(&code, params.webview_label.as_deref())
1812 .await
1813 }
1814 InteractAction::Hover => {
1815 if !self.state.privacy.is_tool_enabled("interact.hover") {
1816 return tool_disabled("interact.hover");
1817 }
1818 let Some(ref_id) = ¶ms.ref_id else {
1819 return missing_param("ref_id", "hover");
1820 };
1821 let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
1822 self.eval_page_action(&code, params.webview_label.as_deref())
1823 .await
1824 }
1825 InteractAction::Focus => {
1826 if !self.state.privacy.is_tool_enabled("interact.focus") {
1827 return tool_disabled("interact.focus");
1828 }
1829 let Some(ref_id) = ¶ms.ref_id else {
1830 return missing_param("ref_id", "focus");
1831 };
1832 let code = format!(
1833 "return window.__VICTAURI__?.focusElement({})",
1834 js_string(ref_id)
1835 );
1836 self.eval_page_action(&code, params.webview_label.as_deref())
1837 .await
1838 }
1839 InteractAction::ScrollIntoView => {
1840 if !self
1841 .state
1842 .privacy
1843 .is_tool_enabled("interact.scroll_into_view")
1844 {
1845 return tool_disabled("interact.scroll_into_view");
1846 }
1847 let ref_arg = params
1848 .ref_id
1849 .as_ref()
1850 .map_or_else(|| "null".to_string(), |r| js_string(r));
1851 let x = params.x.unwrap_or(0.0);
1852 let y = params.y.unwrap_or(0.0);
1853 let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
1854 self.eval_page_action(&code, params.webview_label.as_deref())
1855 .await
1856 }
1857 InteractAction::SelectOption => {
1858 if !self.state.privacy.is_tool_enabled("interact.select_option") {
1859 return tool_disabled("interact.select_option");
1860 }
1861 let Some(ref_id) = ¶ms.ref_id else {
1862 return missing_param("ref_id", "select_option");
1863 };
1864 let values_vec;
1865 let values: &[String] = match (¶ms.values, ¶ms.value) {
1866 (Some(v), _) => v,
1867 (None, Some(v)) => {
1868 values_vec = vec![v.clone()];
1869 &values_vec
1870 }
1871 (None, None) => &[],
1872 };
1873 let values_json =
1874 serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
1875 let code = format!(
1876 "return window.__VICTAURI__?.selectOption({}, {})",
1877 js_string(ref_id),
1878 values_json
1879 );
1880 self.eval_page_action(&code, params.webview_label.as_deref())
1881 .await
1882 }
1883 }
1884 }
1885
1886 #[tool(
1887 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. An input the page refuses (element not fillable, covered, disabled, or ref not found) returns an error with the reason and a [hint: RETRY_LATER|CHECK_INPUT].",
1888 annotations(
1889 read_only_hint = false,
1890 destructive_hint = false,
1891 idempotent_hint = false,
1892 open_world_hint = false
1893 )
1894 )]
1895 async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
1896 match params.action {
1897 InputAction::Fill => {
1898 if !self.state.privacy.is_tool_enabled("fill") {
1899 return tool_disabled("fill");
1900 }
1901 let Some(ref_id) = ¶ms.ref_id else {
1902 return missing_param("ref_id", "fill");
1903 };
1904 let Some(value) = ¶ms.value else {
1905 return missing_param("value", "fill");
1906 };
1907 let code = format!(
1908 "return window.__VICTAURI__?.fill({}, {})",
1909 js_string(ref_id),
1910 js_string(value)
1911 );
1912 self.eval_page_action(&code, params.webview_label.as_deref())
1913 .await
1914 }
1915 InputAction::TypeText => {
1916 if !self.state.privacy.is_tool_enabled("type_text") {
1917 return tool_disabled("type_text");
1918 }
1919 let Some(ref_id) = ¶ms.ref_id else {
1920 return missing_param("ref_id", "type_text");
1921 };
1922 let Some(text) = ¶ms.text else {
1923 return missing_param("text", "type_text");
1924 };
1925 if params.trusted.unwrap_or(false) {
1926 if let Err(refused) = self
1930 .focus_for_trusted_input(ref_id, params.webview_label.as_deref())
1931 .await
1932 {
1933 return refused;
1934 }
1935 let bridge = self.bridge.clone();
1936 let label = params.webview_label.clone();
1937 let text = text.to_string();
1938 let native = tokio::task::spawn_blocking(move || {
1939 bridge.native_type_text(label.as_deref(), &text)
1940 })
1941 .await
1942 .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1943 return match native {
1944 Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1945 Err(e) => tool_error(e),
1946 };
1947 }
1948 let code = format!(
1949 "return window.__VICTAURI__?.type({}, {})",
1950 js_string(ref_id),
1951 js_string(text)
1952 );
1953 self.eval_page_action(&code, params.webview_label.as_deref())
1954 .await
1955 }
1956 InputAction::PressKey => {
1957 if !self.state.privacy.is_tool_enabled("input.press_key") {
1958 return tool_disabled("input.press_key");
1959 }
1960 let Some(key) = ¶ms.key else {
1961 return missing_param("key", "press_key");
1962 };
1963 if params.trusted.unwrap_or(false) {
1964 if let Some(ref_id) = ¶ms.ref_id
1967 && let Err(refused) = self
1968 .focus_for_trusted_input(ref_id, params.webview_label.as_deref())
1969 .await
1970 {
1971 return refused;
1972 }
1973 let bridge = self.bridge.clone();
1974 let label = params.webview_label.clone();
1975 let key = key.to_string();
1976 let native = tokio::task::spawn_blocking(move || {
1977 bridge.native_key(label.as_deref(), &key)
1978 })
1979 .await
1980 .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1981 return match native {
1982 Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1983 Err(e) => tool_error(e),
1984 };
1985 }
1986 let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
1987 self.eval_page_action(&code, params.webview_label.as_deref())
1988 .await
1989 }
1990 }
1991 }
1992
1993 #[tool(
1994 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).",
1995 annotations(
1996 read_only_hint = false,
1997 destructive_hint = true,
1998 idempotent_hint = false,
1999 open_world_hint = false
2000 )
2001 )]
2002 async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
2003 match params.action {
2004 WindowAction::GetState => {
2005 let states = match self.bridge.try_get_window_states(params.label.as_deref()) {
2006 Ok(states) => states,
2007 Err(e) => return tool_error(ui_busy(&e)),
2008 };
2009 if states.is_empty()
2012 && let Some(label) = params.label.as_deref()
2013 {
2014 return tool_error(format!(
2015 "window not found: '{label}' (use window.list to see available labels)"
2016 ));
2017 }
2018 json_result(&states)
2019 }
2020 WindowAction::List => match self.bridge.try_list_window_labels() {
2021 Ok(labels) => json_result(&labels),
2022 Err(e) => tool_error(ui_busy(&e)),
2023 },
2024 WindowAction::Introspectability => self.window_introspectability().await,
2025 WindowAction::Manage => {
2026 if !self.state.privacy.is_tool_enabled("window.manage") {
2027 return tool_disabled("window.manage");
2028 }
2029 let Some(manage_action) = ¶ms.manage_action else {
2030 return missing_param("manage_action", "manage");
2031 };
2032 match self
2033 .bridge
2034 .manage_window(params.label.as_deref(), manage_action.as_str())
2035 {
2036 Ok(msg) => CallToolResult::success(vec![ContentBlock::text(msg)]),
2037 Err(e) => tool_error(e),
2038 }
2039 }
2040 WindowAction::Resize => {
2041 if !self.state.privacy.is_tool_enabled("window.resize") {
2042 return tool_disabled("window.resize");
2043 }
2044 let Some(width) = params.width else {
2045 return missing_param("width", "resize");
2046 };
2047 let Some(height) = params.height else {
2048 return missing_param("height", "resize");
2049 };
2050 if width == 0 || height == 0 {
2051 return tool_error_with_hint(
2052 format!(
2053 "invalid window size {width}x{height}: width and height must be > 0"
2054 ),
2055 RecoveryHint::CheckInput,
2056 );
2057 }
2058 match self
2059 .bridge
2060 .resize_window(params.label.as_deref(), width, height)
2061 {
2062 Ok(()) => {
2063 let result =
2064 serde_json::json!({"ok": true, "width": width, "height": height});
2065 CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2066 }
2067 Err(e) => tool_error(e),
2068 }
2069 }
2070 WindowAction::MoveTo => {
2071 if !self.state.privacy.is_tool_enabled("window.move_to") {
2072 return tool_disabled("window.move_to");
2073 }
2074 let Some(x) = params.x else {
2075 return missing_param("x", "move_to");
2076 };
2077 let Some(y) = params.y else {
2078 return missing_param("y", "move_to");
2079 };
2080 match self.bridge.move_window(params.label.as_deref(), x, y) {
2081 Ok(()) => {
2082 let result = serde_json::json!({"ok": true, "x": x, "y": y});
2083 CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2084 }
2085 Err(e) => tool_error(e),
2086 }
2087 }
2088 WindowAction::SetTitle => {
2089 if !self.state.privacy.is_tool_enabled("window.set_title") {
2090 return tool_disabled("window.set_title");
2091 }
2092 let Some(title) = ¶ms.title else {
2093 return missing_param("title", "set_title");
2094 };
2095 match self.bridge.set_window_title(params.label.as_deref(), title) {
2096 Ok(()) => {
2097 let result = serde_json::json!({"ok": true, "title": title});
2098 CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2099 }
2100 Err(e) => tool_error(e),
2101 }
2102 }
2103 }
2104 }
2105
2106 #[tool(
2107 description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
2108 annotations(
2109 read_only_hint = false,
2110 destructive_hint = true,
2111 idempotent_hint = false,
2112 open_world_hint = false
2113 )
2114 )]
2115 async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
2116 match params.action {
2117 StorageAction::Get => {
2118 let method = match params.storage_type.unwrap_or(StorageType::Local) {
2119 StorageType::Session => "getSessionStorage",
2120 StorageType::Local => "getLocalStorage",
2121 };
2122 let key_arg = params
2123 .key
2124 .as_ref()
2125 .map(|k| js_string(k))
2126 .unwrap_or_default();
2127 let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
2128 self.eval_bridge(&code, params.webview_label.as_deref())
2129 .await
2130 }
2131 StorageAction::Set => {
2132 if !self.state.privacy.is_tool_enabled("set_storage") {
2133 return tool_disabled("set_storage");
2134 }
2135 let method = match params.storage_type.unwrap_or(StorageType::Local) {
2136 StorageType::Session => "setSessionStorage",
2137 StorageType::Local => "setLocalStorage",
2138 };
2139 let Some(key) = ¶ms.key else {
2140 return missing_param("key", "set");
2141 };
2142 if !self.state.privacy.is_storage_key_allowed(key) {
2145 return tool_error(format!(
2146 "storage key '{key}' is protected by privacy configuration"
2147 ));
2148 }
2149 let value = params
2150 .value
2151 .as_ref()
2152 .cloned()
2153 .unwrap_or(serde_json::Value::Null);
2154 let value_json =
2155 serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
2156 let code = format!(
2157 "return window.__VICTAURI__?.{method}({}, {value_json})",
2158 js_string(key)
2159 );
2160 self.eval_bridge(&code, params.webview_label.as_deref())
2161 .await
2162 }
2163 StorageAction::Delete => {
2164 if !self.state.privacy.is_tool_enabled("delete_storage") {
2165 return tool_disabled("delete_storage");
2166 }
2167 let method = match params.storage_type.unwrap_or(StorageType::Local) {
2168 StorageType::Session => "deleteSessionStorage",
2169 StorageType::Local => "deleteLocalStorage",
2170 };
2171 let Some(key) = ¶ms.key else {
2172 return missing_param("key", "delete");
2173 };
2174 let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
2175 self.eval_bridge(&code, params.webview_label.as_deref())
2176 .await
2177 }
2178 StorageAction::GetCookies => {
2179 self.eval_bridge(
2180 "return window.__VICTAURI__?.getCookies()",
2181 params.webview_label.as_deref(),
2182 )
2183 .await
2184 }
2185 }
2186 }
2187
2188 #[tool(
2189 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.",
2190 annotations(
2191 read_only_hint = false,
2192 destructive_hint = false,
2193 idempotent_hint = false,
2194 open_world_hint = false
2195 )
2196 )]
2197 async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
2198 match params.action {
2199 NavigateAction::GoTo => {
2200 if !self.state.privacy.is_tool_enabled("navigate") {
2201 return tool_disabled("navigate");
2202 }
2203 let Some(url) = ¶ms.url else {
2204 return missing_param("url", "go_to");
2205 };
2206 if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
2207 return tool_error(e);
2208 }
2209 let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
2210 self.eval_bridge(&code, params.webview_label.as_deref())
2211 .await
2212 }
2213 NavigateAction::GoBack => {
2214 self.eval_bridge(
2215 "return window.__VICTAURI__?.navigateBack()",
2216 params.webview_label.as_deref(),
2217 )
2218 .await
2219 }
2220 NavigateAction::GetHistory => {
2221 self.eval_bridge(
2222 "return window.__VICTAURI__?.getNavigationLog()",
2223 params.webview_label.as_deref(),
2224 )
2225 .await
2226 }
2227 NavigateAction::SetDialogResponse => {
2228 if !self.state.privacy.is_tool_enabled("set_dialog_response") {
2229 return tool_disabled("set_dialog_response");
2230 }
2231 let Some(dialog_type) = params.dialog_type else {
2232 return missing_param("dialog_type", "set_dialog_response");
2233 };
2234 let Some(dialog_action) = params.dialog_action else {
2235 return missing_param("dialog_action", "set_dialog_response");
2236 };
2237 let text_arg = params
2238 .text
2239 .as_ref()
2240 .map_or_else(|| "undefined".to_string(), |t| js_string(t));
2241 let code = format!(
2242 "return {}?.setDialogAutoResponse({}, {}, {text_arg})",
2243 crate::js_bridge::agent_ops_js(),
2244 js_string(dialog_type.as_str()),
2245 js_string(dialog_action.as_str())
2246 );
2247 self.eval_bridge(&code, params.webview_label.as_deref())
2248 .await
2249 }
2250 NavigateAction::GetDialogLog => {
2251 self.eval_bridge(
2252 "return window.__VICTAURI__?.getDialogLog()",
2253 params.webview_label.as_deref(),
2254 )
2255 .await
2256 }
2257 }
2258 }
2259
2260 #[tool(
2261 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 a session from JSON as the active recording; refused while one is in progress), replay (re-invoke the recorded IPC commands that succeeded with no arguments, each in the window that made it — their side effects happen again; calls that had arguments, failed, never completed, were answered by a route rule, or were captured on Tauri's postMessage IPC path are skipped, and webview_label limits replay to that window's calls), flush (immediately drain pending events into recording without waiting for the 1-second poll).",
2262 annotations(
2263 read_only_hint = false,
2264 destructive_hint = true,
2265 idempotent_hint = false,
2266 open_world_hint = false
2267 )
2268 )]
2269 async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
2270 const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
2271 if !self.state.privacy.is_tool_enabled("recording") {
2272 return tool_disabled("recording");
2273 }
2274 match params.action {
2275 RecordingAction::Start => {
2276 let session_id = params
2277 .session_id
2278 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
2279 let floor_ms = now_ms();
2280 match self.state.recorder.start_session(session_id.clone()) {
2281 Ok(generation) => {
2282 self.state.drain_watermarks.reset(floor_ms, generation);
2283 let result = serde_json::json!({
2284 "started": true,
2285 "session_id": session_id,
2286 });
2287 CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2288 }
2289 Err(e) => tool_error(e.to_string()),
2290 }
2291 }
2292 RecordingAction::Stop => {
2293 let unreachable = if self.state.recorder.is_recording() {
2298 self.final_recording_flush().await
2299 } else {
2300 Vec::new()
2301 };
2302 match self.state.recorder.stop() {
2303 Some(session) => {
2304 if unreachable.is_empty() {
2305 json_result(&session)
2306 } else {
2307 let mut value =
2308 serde_json::to_value(&session).unwrap_or(serde_json::Value::Null);
2309 if let Some(obj) = value.as_object_mut() {
2310 obj.insert(
2311 "final_flush_unreachable".to_string(),
2312 serde_json::json!(unreachable),
2313 );
2314 }
2315 json_result(&value)
2316 }
2317 }
2318 None => tool_error("no recording is active"),
2319 }
2320 }
2321 RecordingAction::Checkpoint => {
2322 let id = params
2327 .checkpoint_id
2328 .unwrap_or_else(|| format!("cp-{}", uuid::Uuid::new_v4()));
2329 let state = params.state.unwrap_or(serde_json::Value::Null);
2330 match self
2331 .state
2332 .recorder
2333 .checkpoint(id.clone(), params.checkpoint_label, state)
2334 {
2335 Ok(()) => {
2336 let result = serde_json::json!({
2337 "created": true,
2338 "checkpoint_id": id,
2339 "event_index": self.state.recorder.event_count(),
2340 });
2341 CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2342 }
2343 Err(e) => tool_error(e.to_string()),
2344 }
2345 }
2346 RecordingAction::ListCheckpoints => {
2347 let checkpoints = self.state.recorder.get_checkpoints();
2348 json_result(&checkpoints)
2349 }
2350 RecordingAction::GetEvents => {
2351 let events = self
2352 .state
2353 .recorder
2354 .events_since(params.since_index.unwrap_or(0));
2355 json_result(&events)
2356 }
2357 RecordingAction::EventsBetween => {
2358 let Some(from) = ¶ms.from else {
2359 return missing_param("from", "events_between");
2360 };
2361 let Some(to) = ¶ms.to else {
2362 return missing_param("to", "events_between");
2363 };
2364 match self.state.recorder.events_between_checkpoints(from, to) {
2365 Ok(events) => json_result(&events),
2366 Err(e) => tool_error(e.to_string()),
2367 }
2368 }
2369 RecordingAction::GetReplay => {
2370 let calls = self.state.recorder.ipc_replay_sequence();
2371 json_result(&calls)
2372 }
2373 RecordingAction::Export => match self.state.recorder.export() {
2374 Some(s) => {
2375 let json = serde_json::to_string_pretty(&s)
2376 .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
2377 CallToolResult::success(vec![ContentBlock::text(json)])
2378 }
2379 None => tool_error("no recording is active — start one first"),
2380 },
2381 RecordingAction::Import => {
2382 let Some(session_json) = ¶ms.session_json else {
2383 return missing_param("session_json", "import");
2384 };
2385 if session_json.len() > MAX_SESSION_JSON {
2386 return tool_error("session JSON exceeds maximum size (10 MB)");
2387 }
2388 let session: victauri_core::RecordedSession =
2389 match serde_json::from_str(session_json) {
2390 Ok(s) => s,
2391 Err(e) => return tool_error(format!("invalid session JSON: {e}")),
2392 };
2393
2394 let result = serde_json::json!({
2395 "imported": true,
2396 "session_id": session.id,
2397 "event_count": session.events.len(),
2398 "checkpoint_count": session.checkpoints.len(),
2399 "started_at": session.started_at.to_rfc3339(),
2400 "recording_active": true,
2401 "note": "the imported session is now the ACTIVE recording: live events are \
2402 appended to it until you call recording action=stop",
2403 });
2404 let floor_ms = now_ms();
2408 match self.state.recorder.import_if_idle(session) {
2409 Ok(generation) => self.state.drain_watermarks.reset(floor_ms, generation),
2410 Err(_) => {
2411 return tool_error(
2412 "a recording is in progress — import would discard it. Stop it first \
2413 (recording action=stop, or export it), then import.",
2414 );
2415 }
2416 }
2417 CallToolResult::success(vec![ContentBlock::text(result.to_string())])
2418 }
2419 RecordingAction::Flush => {
2420 if !self.state.recorder.is_recording() {
2421 return tool_error("no active recording — start a recording first");
2422 }
2423 let labels: Vec<String> = match params.webview_label.as_deref() {
2427 Some(l) => vec![l.to_string()],
2428 None => match self.bridge.try_list_window_labels() {
2429 Ok(labels) => labels,
2430 Err(e) => return tool_error(ui_busy(&e)),
2431 },
2432 };
2433 let mut captured = 0usize;
2434 let mut failed = Vec::new();
2435 for label in &labels {
2436 match crate::mcp::server::drain_window_into_recording(
2437 &self.state,
2438 &self.bridge,
2439 label,
2440 )
2441 .await
2442 {
2443 Some(n) => captured += n,
2444 None => failed.push(label.clone()),
2445 }
2446 }
2447 if !labels.is_empty() && failed.len() == labels.len() {
2448 return tool_error(format!(
2449 "flush failed: no window answered ({})",
2450 failed.join(", ")
2451 ));
2452 }
2453 json_result(&serde_json::json!({
2454 "flushed": true,
2455 "events_captured": captured,
2456 "windows": labels,
2457 "unreachable_windows": failed,
2458 }))
2459 }
2460 RecordingAction::Replay => {
2461 let calls = self.state.recorder.ipc_replay_sequence();
2462 if calls.is_empty() {
2463 return tool_error("no IPC calls recorded — record a session first");
2464 }
2465 let mut replay_results = Vec::new();
2466 for call in &calls {
2467 let skip_reason = match &call.result {
2475 _ if call.mocked => Some(
2476 "the original call was answered by a route rule in the page, not the backend".to_string(),
2477 ),
2478 _ if call.is_post_message() => Some(
2482 "the original call was captured on Tauri's postMessage IPC path, where the bridge cannot prove it was really sent".to_string(),
2483 ),
2484 victauri_core::IpcResult::Ok(_) if call.arg_size_bytes > 0 => Some(
2485 "the original call had arguments, which recordings do not capture".to_string(),
2486 ),
2487 victauri_core::IpcResult::Ok(_) => params
2488 .webview_label
2489 .as_deref()
2490 .filter(|only| *only != call.webview_label)
2491 .map(|only| {
2492 format!(
2493 "recorded in window '{}'; replay was limited to '{only}'",
2494 call.webview_label
2495 )
2496 }),
2497 victauri_core::IpcResult::Err(_) => {
2498 Some("the original call failed".to_string())
2499 }
2500 _ => Some("the original call never completed".to_string()),
2501 };
2502 if let Some(reason) = skip_reason {
2503 replay_results.push(serde_json::json!({
2504 "command": call.command,
2505 "status": "skipped",
2506 "reason": reason,
2507 }));
2508 continue;
2509 }
2510 if !self.state.privacy.is_invoke_allowed(&call.command)
2514 || !self.state.privacy.is_command_allowed(&call.command)
2515 {
2516 replay_results.push(serde_json::json!({
2517 "command": call.command,
2518 "status": "blocked",
2519 "error": "blocked by privacy configuration",
2520 }));
2521 continue;
2522 }
2523 let code = format!(
2524 "return window.__TAURI_INTERNALS__.invoke({})",
2525 js_string(&call.command)
2526 );
2527 let outcome = match self
2532 .eval_with_return(&code, Some(call.webview_label.as_str()))
2533 .await
2534 {
2535 Ok(result_str) => {
2536 let value: serde_json::Value = serde_json::from_str(&result_str)
2537 .unwrap_or(serde_json::Value::String(result_str));
2538 let shape = crate::introspection::JsonShape::from_value(&value);
2539 serde_json::json!({
2540 "command": call.command,
2541 "webview_label": call.webview_label,
2542 "status": "ok",
2543 "response_type": shape.type_name(),
2544 })
2545 }
2546 Err(e) => {
2547 serde_json::json!({
2548 "command": call.command,
2549 "status": "error",
2550 "error": e,
2551 })
2552 }
2553 };
2554 replay_results.push(outcome);
2555 }
2556 let count = |status: &str| {
2557 replay_results
2558 .iter()
2559 .filter(|r| r.get("status").and_then(|s| s.as_str()) == Some(status))
2560 .count()
2561 };
2562 let (passed, skipped) = (count("ok"), count("skipped"));
2563 let replayed = replay_results.len() - skipped;
2564 let result = serde_json::json!({
2565 "replayed": replayed,
2566 "passed": passed,
2567 "failed": replayed - passed,
2568 "skipped": skipped,
2569 "note": "commands are re-invoked WITHOUT arguments (recordings do not capture \
2570 them), each in the window that recorded it, and their side effects \
2571 happen again; calls that had arguments, failed, never completed, \
2572 were answered by a route rule, or were captured on Tauri's \
2573 postMessage IPC path are skipped",
2574 "results": replay_results,
2575 });
2576 json_result(&result)
2577 }
2578 }
2579 }
2580
2581 #[tool(
2582 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).",
2583 annotations(
2584 read_only_hint = false,
2585 destructive_hint = false,
2586 idempotent_hint = true,
2587 open_world_hint = false
2588 )
2589 )]
2590 async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
2591 match params.action {
2592 InspectAction::GetStyles => {
2593 let Some(ref_id) = ¶ms.ref_id else {
2594 return missing_param("ref_id", "get_styles");
2595 };
2596 let props_arg = match ¶ms.properties {
2597 Some(props) => {
2598 let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
2599 format!("[{}]", arr.join(","))
2600 }
2601 None => "null".to_string(),
2602 };
2603 let code = format!(
2604 "return window.__VICTAURI__?.getStyles({}, {})",
2605 js_string(ref_id),
2606 props_arg
2607 );
2608 self.eval_page_action(&code, params.webview_label.as_deref())
2609 .await
2610 }
2611 InspectAction::GetBoundingBoxes => {
2612 let Some(ref_ids) = ¶ms.ref_ids else {
2613 return missing_param("ref_ids", "get_bounding_boxes");
2614 };
2615 let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
2616 let code = format!(
2617 "return window.__VICTAURI__?.getBoundingBoxes([{}])",
2618 refs.join(",")
2619 );
2620 self.eval_page_action(&code, params.webview_label.as_deref())
2621 .await
2622 }
2623 InspectAction::Highlight => {
2624 if !self.state.privacy.is_tool_enabled("inspect.highlight") {
2628 return tool_disabled("inspect.highlight");
2629 }
2630 let Some(ref_id) = ¶ms.ref_id else {
2631 return missing_param("ref_id", "highlight");
2632 };
2633 let color_arg = match ¶ms.color {
2634 Some(c) => match sanitize_css_color(c) {
2635 Ok(safe) => format!("\"{safe}\""),
2636 Err(e) => return tool_error(e),
2637 },
2638 None => "null".to_string(),
2639 };
2640 let label_arg = match ¶ms.label {
2641 Some(l) => js_string(l),
2642 None => "null".to_string(),
2643 };
2644 let code = format!(
2645 "return window.__VICTAURI__?.highlightElement({}, {}, {})",
2646 js_string(ref_id),
2647 color_arg,
2648 label_arg
2649 );
2650 self.eval_page_action(&code, params.webview_label.as_deref())
2651 .await
2652 }
2653 InspectAction::ClearHighlights => {
2654 if !self
2655 .state
2656 .privacy
2657 .is_tool_enabled("inspect.clear_highlights")
2658 {
2659 return tool_disabled("inspect.clear_highlights");
2660 }
2661 self.eval_page_action(
2662 "return window.__VICTAURI__?.clearHighlights()",
2663 params.webview_label.as_deref(),
2664 )
2665 .await
2666 }
2667 InspectAction::AuditAccessibility => {
2668 self.eval_page_action(
2669 "return window.__VICTAURI__?.auditAccessibility()",
2670 params.webview_label.as_deref(),
2671 )
2672 .await
2673 }
2674 InspectAction::GetPerformance => {
2675 self.eval_page_action(
2676 "return window.__VICTAURI__?.getPerformanceMetrics()",
2677 params.webview_label.as_deref(),
2678 )
2679 .await
2680 }
2681 }
2682 }
2683
2684 #[tool(
2685 description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
2686 annotations(
2687 read_only_hint = false,
2688 destructive_hint = false,
2689 idempotent_hint = true,
2690 open_world_hint = false
2691 )
2692 )]
2693 async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
2694 match params.action {
2695 CssAction::Inject => {
2696 if !self.state.privacy.is_tool_enabled("inject_css") {
2697 return tool_disabled("inject_css");
2698 }
2699 let Some(css) = ¶ms.css else {
2700 return missing_param("css", "inject");
2701 };
2702 if let Err(e) = sanitize_injected_css(css, params.allow_remote) {
2704 return tool_error(e);
2705 }
2706 let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
2707 self.eval_bridge(&code, params.webview_label.as_deref())
2708 .await
2709 }
2710 CssAction::Remove => {
2711 if !self.state.privacy.is_tool_enabled("css.remove") {
2712 return tool_disabled("css.remove");
2713 }
2714 self.eval_bridge(
2715 "return window.__VICTAURI__?.removeInjectedCss()",
2716 params.webview_label.as_deref(),
2717 )
2718 .await
2719 }
2720 }
2721 }
2722
2723 #[tool(
2724 description = "Network request interception (Playwright route() equivalent, no CDP). \
2725 Matches webview fetch/XHR by URL and blocks, mocks, or delays them. \
2726 Actions:\n\
2727 - `add`: add a rule. `pattern` (+ optional `match_type`: substring/glob/regex/exact, \
2728 and `method`) selects requests; `behavior` is `block` (abort), `fulfill` (return a \
2729 mock `status`/`headers`/`body`/`content_type`), or `delay` (proceed after `delay_ms`). \
2730 `times` limits how often it fires. Rules are page-scoped (cleared on reload).\n\
2731 - `list`: list active rules.\n\
2732 - `clear` (by `id`) / `clear_all`: remove rules.\n\
2733 - `matches`: log of intercepted requests.\n\
2734 Note: fetch supports all behaviors; XHR supports block/delay (fulfill is fetch-only). \
2735 Top-level navigation, sub-resource (img/css), and WebSocket traffic are not intercepted. \
2736 Tauri IPC: on the fetch transport (desktop, until a fallback) a rule matching an IPC URL \
2737 (http(s)://ipc.localhost/<cmd> or ipc://localhost/<cmd>) DOES change what the app's real \
2738 `invoke` gets. `fulfill` mocks the command (the Rust handler never runs; invoke resolves \
2739 only with a `Tauri-Response: ok` header, else rejects with the body); `delay` holds the \
2740 call back; `block` does NOT fail it (Tauri retries over postMessage and stays there until \
2741 reload). Mocked calls are tagged `mocked` in the IPC log and kept out of ghost detection, \
2742 command_catalog, coverage and timings; `add` returns an `ipc_note` when a rule can match \
2743 IPC. postMessage IPC (Android, after a fallback) is not affected. The `fault` tool \
2744 instead acts on commands you drive via `invoke_command`.",
2745 annotations(
2746 read_only_hint = false,
2747 destructive_hint = false,
2748 idempotent_hint = false,
2749 open_world_hint = false
2750 )
2751 )]
2752 async fn route(&self, Parameters(params): Parameters<RouteParams>) -> CallToolResult {
2753 match params.action {
2754 RouteAction::Add => {
2755 if !self.state.privacy.is_tool_enabled("route.add") {
2756 return tool_disabled("route.add");
2757 }
2758 let Some(pattern) = ¶ms.pattern else {
2759 return missing_param("pattern", "add");
2760 };
2761 let behavior = params.behavior.unwrap_or(RouteBehavior::Fulfill);
2762 let match_type = params.match_type.unwrap_or(RouteMatchType::Substring);
2763 let mut rule = serde_json::json!({
2764 "pattern": pattern,
2765 "match_type": match_type.as_str(),
2766 "action": behavior.as_str(),
2767 });
2768 if let Some(m) = ¶ms.method {
2769 rule["method"] = serde_json::json!(m);
2770 }
2771 if let Some(s) = params.status {
2772 rule["status"] = serde_json::json!(s);
2773 }
2774 if let Some(st) = ¶ms.status_text {
2775 rule["status_text"] = serde_json::json!(st);
2776 }
2777 if let Some(h) = ¶ms.headers {
2778 rule["headers"] = h.clone();
2779 }
2780 if let Some(b) = ¶ms.body {
2781 rule["body"] = match b {
2784 serde_json::Value::String(s) => serde_json::json!(s),
2785 other => serde_json::json!(other.to_string()),
2786 };
2787 }
2788 if let Some(ct) = ¶ms.content_type {
2789 rule["content_type"] = serde_json::json!(ct);
2790 }
2791 if let Some(d) = params.delay_ms {
2792 if d > MAX_FAULT_DELAY_MS {
2795 return tool_error_with_hint(
2796 format!("delay_ms {d} exceeds the maximum of {MAX_FAULT_DELAY_MS} ms"),
2797 RecoveryHint::CheckInput,
2798 );
2799 }
2800 rule["delay_ms"] = serde_json::json!(d);
2801 }
2802 if let Some(t) = params.times {
2803 rule["times"] = serde_json::json!(t);
2804 }
2805 let code = format!(
2806 "return window.__VICTAURI__?.addRoute({})",
2807 js_string(&rule.to_string())
2808 );
2809 self.eval_page_action(&code, params.webview_label.as_deref())
2810 .await
2811 }
2812 RouteAction::List => {
2813 self.eval_bridge(
2814 "return window.__VICTAURI__?.getRouteRules()",
2815 params.webview_label.as_deref(),
2816 )
2817 .await
2818 }
2819 RouteAction::Clear => {
2820 let Some(id) = params.id else {
2821 return missing_param("id", "clear");
2822 };
2823 let code = format!(
2824 "return {}?.clearRoute({id})",
2825 crate::js_bridge::agent_ops_js()
2826 );
2827 self.eval_bridge(&code, params.webview_label.as_deref())
2828 .await
2829 }
2830 RouteAction::ClearAll => {
2831 let code = format!("return {}?.clearRoutes()", crate::js_bridge::agent_ops_js());
2832 self.eval_bridge(&code, params.webview_label.as_deref())
2833 .await
2834 }
2835 RouteAction::Matches => {
2836 let limit = params.limit.unwrap_or(100);
2837 if limit == 0 {
2839 return CallToolResult::success(vec![ContentBlock::text("[]")]);
2840 }
2841 let code = format!("return window.__VICTAURI__?.getRouteMatches({limit})");
2842 self.eval_bridge(&code, params.webview_label.as_deref())
2843 .await
2844 }
2845 }
2846 }
2847
2848 #[tool(
2849 description = "Screencast / visual trace (no CDP). Captures the window at a fixed interval \
2850 into a ring buffer, forming a visual timeline that pairs with `recording` (events) and \
2851 `logs` (network/console). Actions:\n\
2852 - `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set \
2853 `with_events=true` to also start the event recorder. Hidden windows are skipped \
2854 (no frame), the buffer is capped at 256 MB, and an abandoned trace auto-stops \
2855 after 30 minutes.\n\
2856 - `stop`: stop and return a summary (frame count, duration, timestamps); also stops \
2857 the recording that `with_events` started (never one started separately).\n\
2858 - `status`: active flag + buffered frame count.\n\
2859 - `frames`: return captured frames as base64 PNGs, newest first up to a 25 MB \
2860 response (`limit` caps how many).",
2861 annotations(
2862 read_only_hint = false,
2863 destructive_hint = false,
2864 idempotent_hint = false,
2865 open_world_hint = false
2866 )
2867 )]
2868 async fn trace(&self, Parameters(params): Parameters<TraceParams>) -> CallToolResult {
2869 if !self.state.privacy.is_tool_enabled("trace")
2870 || !self.state.privacy.is_tool_enabled("screenshot")
2871 {
2872 return tool_disabled("trace");
2873 }
2874 match params.action {
2875 TraceAction::Start => {
2876 let interval = params.interval_ms.unwrap_or(500);
2877 let max_frames = params.max_frames.unwrap_or(60);
2878 let label = params.webview_label.clone();
2879 let (generation, superseded) =
2883 self.state
2884 .screencast
2885 .start(interval, max_frames, label.clone());
2886 if let Some(prev) = superseded {
2887 let _ = self.state.recorder.stop_if_generation(prev);
2888 }
2889
2890 let mut events_started = false;
2891 if params.with_events.unwrap_or(false) {
2892 let session_id = uuid::Uuid::new_v4().to_string();
2893 let floor_ms = now_ms();
2894 if let Ok(recording) = self.state.recorder.start_session(session_id) {
2895 self.state.drain_watermarks.reset(floor_ms, recording);
2896 if self
2901 .state
2902 .screencast
2903 .set_owned_recording(generation, recording)
2904 {
2905 events_started = true;
2906 } else {
2907 let _ = self.state.recorder.stop_if_generation(recording);
2908 }
2909 }
2910 }
2911
2912 let handler = self.clone();
2917 let screencast = self.state.screencast.clone();
2918 let guard = crate::screencast::CaptureTaskGuard {
2919 screencast: Arc::clone(&screencast),
2920 recorder: self.state.recorder.clone(),
2921 generation,
2922 };
2923 tokio::spawn(async move {
2924 let _guard = guard;
2925 let t0 = std::time::Instant::now();
2926 while screencast.is_current(generation) && t0.elapsed() < MAX_TRACE_DURATION {
2930 if let Ok(target) = handler.resolve_visible_capture_target(label.as_deref())
2933 && let Ok(handle) = handler.bridge.get_native_handle(Some(&target))
2934 && let Ok(png) = crate::screenshot::capture_window(handle).await
2935 {
2936 use base64::Engine;
2937 let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2938 #[allow(clippy::cast_possible_truncation)]
2939 screencast.push_frame_if_current(
2940 generation,
2941 t0.elapsed().as_millis() as u64,
2942 b64,
2943 );
2944 }
2945 tokio::time::sleep(std::time::Duration::from_millis(
2946 screencast.interval_ms(),
2947 ))
2948 .await;
2949 }
2950 });
2951
2952 json_result(&serde_json::json!({
2953 "started": true,
2954 "interval_ms": self.state.screencast.interval_ms(),
2955 "max_frames": max_frames.clamp(1, 600),
2956 "with_events": events_started,
2957 }))
2958 }
2959 TraceAction::Stop => {
2960 let (frame_count, owned) = self.state.screencast.stop();
2961 let timestamps = self.state.screencast.frame_timestamps();
2962 let duration_ms = timestamps.last().copied().unwrap_or(0);
2963 let event_count = match owned {
2967 Some(owned) => self
2968 .state
2969 .recorder
2970 .stop_if_generation(owned)
2971 .map_or(0, |session| session.events.len()),
2972 None => self.state.recorder.event_count(),
2973 };
2974 json_result(&serde_json::json!({
2975 "stopped": true,
2976 "frame_count": frame_count,
2977 "duration_ms": duration_ms,
2978 "frame_timestamps_ms": timestamps,
2979 "recorded_event_count": event_count,
2980 "hint": "use action=frames to retrieve PNGs; pair with recording/get_events and logs for a full bundle",
2981 }))
2982 }
2983 TraceAction::Status => json_result(&serde_json::json!({
2984 "active": self.state.screencast.is_active(),
2985 "frame_count": self.state.screencast.frame_count(),
2986 "interval_ms": self.state.screencast.interval_ms(),
2987 })),
2988 TraceAction::Frames => {
2989 let limit = params.limit.unwrap_or(0);
2990 let frames = self.state.screencast.frames(limit);
2991 if frames.is_empty() {
2992 return json_result(&serde_json::json!({ "frames": 0 }));
2993 }
2994 let available = frames.len();
2996 let mut budget = MAX_TRACE_FRAMES_RESPONSE_BYTES;
2997 let mut kept: Vec<ContentBlock> = Vec::new();
2998 for f in frames.into_iter().rev() {
2999 if f.data_b64.len() > budget && !kept.is_empty() {
3000 break;
3001 }
3002 budget = budget.saturating_sub(f.data_b64.len());
3003 kept.push(ContentBlock::image(f.data_b64, "image/png"));
3004 }
3005 kept.reverse();
3006 if kept.len() < available {
3007 kept.insert(
3008 0,
3009 ContentBlock::text(format!(
3010 "returned the newest {} of {available} frames (response capped at \
3011 {} MB); pass a smaller `limit` to page",
3012 kept.len(),
3013 MAX_TRACE_FRAMES_RESPONSE_BYTES / (1024 * 1024)
3014 )),
3015 );
3016 }
3017 CallToolResult::success(kept)
3018 }
3019 }
3020 }
3021
3022 #[tool(
3023 description = "Animation introspection (no CDP). Reads the Web Animations API to reveal what \
3024 the webview's animation engine is actually running — duration, delay, easing, iterations, \
3025 keyframes, current progress, and the animating element. Standard DOM, so it works \
3026 identically on WebView2/WKWebView/WebKitGTK. Actions:\n\
3027 - `list`: return all running CSS animations/transitions (optionally scoped by `selector`), \
3028 each with declared `timing`, `computed` progress, `keyframes`, and `target`.\n\
3029 - `scrub`: deterministically pause the target's animation and seek it to `points` \
3030 evenly-spaced steps (default 20), returning the exact geometry curve (rect + transform \
3031 + opacity per step). With `capture=true`, also returns a single contact-sheet filmstrip \
3032 PNG (one image of the whole arc) plus a `manifest` mapping each cell to its progress/time. \
3033 Frozen frames are jank-free, so this beats real-time capture for fast sweeps. CSS-driven \
3034 animations only (JS/rAF animations are not seekable — use `list`/`sample`).\n\
3035 - `sample`: real-time motion recorder. `record=true` arms a requestAnimationFrame watcher \
3036 on `selector` (or the first animating element); then trigger the animation; then call \
3037 with `record=false` to read the measured per-frame curve plus jank stats (dropped frames, \
3038 max frame gap) and declared-vs-measured duration. Works for ANY animation including \
3039 JS/rAF-driven ones. `clear=true` resets recorded sessions.\n\
3040 NOTE: an animation only appears while it is running or pending — trigger it (e.g. show the \
3041 notification) just before calling `list`/`scrub`, or arm `sample` before triggering.",
3042 annotations(
3043 read_only_hint = false,
3044 destructive_hint = false,
3045 idempotent_hint = false,
3046 open_world_hint = false
3047 )
3048 )]
3049 async fn animation(&self, Parameters(params): Parameters<AnimationParams>) -> CallToolResult {
3050 if !self.state.privacy.is_tool_enabled("animation") {
3051 return tool_disabled("animation");
3052 }
3053 match params.action {
3054 AnimationAction::List => {
3055 let sel = params
3056 .selector
3057 .as_deref()
3058 .map_or_else(|| "null".to_string(), js_string);
3059 let code = format!(
3060 "return window.__VICTAURI__ && window.__VICTAURI__.listAnimations({sel})"
3061 );
3062 match self
3063 .eval_with_return(&code, params.webview_label.as_deref())
3064 .await
3065 {
3066 Ok(result_str) => {
3067 match serde_json::from_str::<serde_json::Value>(&result_str) {
3068 Ok(v) => json_result(&v),
3069 Err(_) => CallToolResult::success(vec![ContentBlock::text(result_str)]),
3070 }
3071 }
3072 Err(e) => tool_error(format!("animation list failed: {e}")),
3073 }
3074 }
3075 AnimationAction::Scrub => {
3076 if params.capture.unwrap_or(false)
3080 && !self.state.privacy.is_tool_enabled("screenshot")
3081 {
3082 return tool_error_with_hint(
3083 "animation scrub with capture=true takes native window screenshots, but \
3084 tool 'screenshot' is disabled by privacy configuration — call scrub \
3085 without `capture` to get the geometry curve only",
3086 RecoveryHint::ReportToUser,
3087 );
3088 }
3089 self.animation_scrub(params).await
3090 }
3091 AnimationAction::Sample => {
3092 let label = params.webview_label.as_deref();
3093 let sel = params
3094 .selector
3095 .as_deref()
3096 .map_or_else(|| "null".to_string(), js_string);
3097 let code = if params.record.unwrap_or(false) {
3098 crate::js_bridge::agent_op_call_js(&format!("installSweepRecorder({sel})"))
3099 } else {
3100 let clear = params.clear.unwrap_or(false);
3101 crate::js_bridge::agent_op_call_js(&format!("readSweep({clear})"))
3102 };
3103 match self.eval_with_return(&code, label).await {
3104 Ok(result_str) => {
3105 match serde_json::from_str::<serde_json::Value>(&result_str) {
3106 Ok(v) => json_result(&v),
3107 Err(_) => CallToolResult::success(vec![ContentBlock::text(result_str)]),
3108 }
3109 }
3110 Err(e) => tool_error(format!("animation sample failed: {e}")),
3111 }
3112 }
3113 }
3114 }
3115
3116 async fn animation_scrub(&self, params: AnimationParams) -> CallToolResult {
3119 let label = params.webview_label.as_deref();
3120 let sel = params
3121 .selector
3122 .as_deref()
3123 .map_or_else(|| "null".to_string(), js_string);
3124
3125 let prep_code = crate::js_bridge::agent_op_call_js(&format!("scrubPrepare({sel})"));
3127 let prep_v = match self.eval_with_return(&prep_code, label).await {
3128 Ok(s) => {
3129 serde_json::from_str::<serde_json::Value>(&s).unwrap_or(serde_json::Value::Null)
3130 }
3131 Err(e) => return tool_error(format!("scrub prepare failed: {e}")),
3132 };
3133 if prep_v.get("prepared").and_then(serde_json::Value::as_bool) != Some(true) {
3134 return json_result(&prep_v);
3136 }
3137
3138 let points = params.points.unwrap_or(20).clamp(2, 120);
3139 let capture = params.capture.unwrap_or(false);
3140 let cols_for = |n: usize| {
3141 params
3142 .cols
3143 .unwrap_or_else(|| crate::filmstrip::default_cols(n))
3144 };
3145 let resume = params.restore.unwrap_or(true);
3146 let restore_code = crate::js_bridge::agent_op_call_js(&format!("scrubRestore({resume})"));
3147 let mut curve: Vec<serde_json::Value> = Vec::with_capacity(points);
3148 let mut frames: Vec<crate::filmstrip::Frame> = Vec::new();
3149 let mut manifest: Vec<serde_json::Value> = Vec::new();
3150 let mut capture_error: Option<String> = None;
3152
3153 for i in 0..points {
3155 #[allow(clippy::cast_precision_loss)]
3156 let progress = i as f64 / (points - 1) as f64;
3157 let seek_code = crate::js_bridge::agent_op_call_js(&format!("scrubSeek({progress})"));
3158 match self.eval_with_return(&seek_code, label).await {
3159 Ok(s) => {
3160 let v = serde_json::from_str::<serde_json::Value>(&s)
3161 .unwrap_or(serde_json::Value::Null);
3162 if capture {
3163 match self.capture_scrub_frame(label).await {
3164 Ok(frame) => {
3165 if frames.is_empty()
3168 && let Err(e) = crate::filmstrip::check_sheet(
3169 frame.w,
3170 frame.h,
3171 points,
3172 cols_for(points),
3173 FILMSTRIP_GAP,
3174 )
3175 {
3176 let _ = self.eval_with_return(&restore_code, label).await;
3177 let fitting = (2..points).rev().find(|&n| {
3178 crate::filmstrip::check_sheet(
3179 frame.w,
3180 frame.h,
3181 n,
3182 cols_for(n),
3183 FILMSTRIP_GAP,
3184 )
3185 .is_ok()
3186 });
3187 let advice = fitting.map_or_else(
3188 || "call scrub without `capture` for the geometry curve (or shrink the window)"
3189 .to_string(),
3190 |n| format!("use `points` <= {n} (or more `cols`)"),
3191 );
3192 return tool_error(format!(
3193 "animation scrub capture refused before capturing: {e}; {advice}"
3194 ));
3195 }
3196 manifest.push(serde_json::json!({
3197 "cell": frames.len(),
3198 "progress": progress,
3199 "t": v.get("t").cloned().unwrap_or(serde_json::Value::Null),
3200 }));
3201 frames.push(frame);
3202 }
3203 Err(e) => {
3204 capture_error.get_or_insert(e);
3205 }
3206 }
3207 }
3208 curve.push(v);
3209 }
3210 Err(e) => curve.push(serde_json::json!({ "progress": progress, "error": e })),
3211 }
3212 }
3213
3214 let _ = self.eval_with_return(&restore_code, label).await;
3216
3217 let mut meta = serde_json::json!({
3218 "scrubbed": true,
3219 "points": points,
3220 "duration_ms": prep_v.get("duration").cloned().unwrap_or(serde_json::Value::Null),
3221 "anim_count": prep_v.get("anim_count").cloned().unwrap_or(serde_json::Value::Null),
3222 "target": prep_v.get("target").cloned().unwrap_or(serde_json::Value::Null),
3223 "captured": false,
3225 "curve": curve,
3226 });
3227 if let Some(e) = &capture_error {
3228 meta["capture_error"] = serde_json::json!(e);
3229 }
3230
3231 if capture && !frames.is_empty() {
3233 let cols = cols_for(frames.len());
3234 let composed = tokio::task::spawn_blocking(move || -> Result<_, String> {
3237 use base64::Engine;
3238 let (rgba, w, h) =
3239 crate::filmstrip::compose(&frames, cols, FILMSTRIP_GAP, [20, 20, 20, 255])
3240 .map_err(|e| format!("filmstrip compose failed: {e}"))?;
3241 drop(frames);
3242 let png = crate::screenshot::encode_png(w, h, &rgba)
3243 .map_err(|e| format!("filmstrip encode failed: {e}"))?;
3244 Ok((base64::engine::general_purpose::STANDARD.encode(&png), w, h))
3245 })
3246 .await
3247 .unwrap_or_else(|e| Err(format!("filmstrip composition failed: {e}")));
3248 match composed {
3249 Ok((b64, w, h)) => {
3250 meta["captured"] = serde_json::json!(true);
3251 meta["filmstrip"] = serde_json::json!({
3252 "cols": cols,
3253 "frame_count": manifest.len(),
3254 "width": w,
3255 "height": h,
3256 "manifest": manifest,
3257 });
3258 return CallToolResult::success(vec![
3259 ContentBlock::image(b64, "image/png"),
3260 ContentBlock::text(meta.to_string()),
3261 ]);
3262 }
3263 Err(e) => return tool_error(e),
3264 }
3265 }
3266
3267 json_result(&meta)
3268 }
3269
3270 async fn capture_scrub_frame(
3272 &self,
3273 label: Option<&str>,
3274 ) -> Result<crate::filmstrip::Frame, String> {
3275 let handle = self
3276 .bridge
3277 .get_native_handle(label)
3278 .map_err(|e| format!("no native window handle: {e}"))?;
3279 let (rgba, w, h) = crate::screenshot::capture_window_raw(handle)
3280 .await
3281 .map_err(|e| format!("window capture failed: {e}"))?;
3282 crate::filmstrip::Frame::new(rgba, w, h)
3283 .ok_or_else(|| format!("window capture returned a malformed {w}x{h} frame"))
3284 }
3285
3286 #[tool(
3287 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; entries captured on Tauri's postMessage IPC fallback carry transport:'post_message', see get_diagnostics ipc_capture), navigation (URL change history), dialogs (alert/confirm/prompt events), events (combined event stream), slow_ipc (find slow IPC calls), clear (DELETES the captured IPC + network logs — use for per-test isolation). BACKEND (the Rust side — what the app prints to its debug console): backend (structured Rust log entries — tracing/log records with typed fields + span context, plus captured panics; filter by level / target prefix / filter text / `fields` (structured-field equality), page with since_seq→next_seq), backend_digest (START HERE: counts per level, noisiest targets, repeated messages collapsed into templates, recent warnings/errors, every panic — one cheap call instead of paging thousands of lines), stdout (raw stdout/stderr lines when the app was launched via `victauri run` — also shows how the process exited).",
3288 annotations(
3289 read_only_hint = false,
3290 destructive_hint = true,
3291 idempotent_hint = true,
3292 open_world_hint = false
3293 )
3294 )]
3295 async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
3296 if params.limit == Some(0)
3300 && matches!(
3301 params.action,
3302 LogsAction::Console
3303 | LogsAction::Network
3304 | LogsAction::Ipc
3305 | LogsAction::Navigation
3306 | LogsAction::Dialogs
3307 | LogsAction::Events
3308 )
3309 {
3310 return CallToolResult::success(vec![ContentBlock::text("[]")]);
3311 }
3312 match params.action {
3313 LogsAction::Console => {
3314 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
3315 let base = if since_arg.is_empty() {
3316 "window.__VICTAURI__?.getConsoleLogs()".to_string()
3317 } else {
3318 format!("window.__VICTAURI__?.getConsoleLogs({since_arg})")
3319 };
3320 let code = if let Some(limit) = params.limit {
3321 format!("return ({base} || []).slice(-{limit})")
3322 } else {
3323 format!("return {base}")
3324 };
3325 self.eval_bridge(&code, params.webview_label.as_deref())
3326 .await
3327 }
3328 LogsAction::Network => {
3329 let filter_arg = params
3330 .filter
3331 .as_ref()
3332 .map_or_else(|| "null".to_string(), |f| js_string(f));
3333 let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
3334 let source = format!("window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit})");
3335 let code = trimmed_log_js(&source, limit);
3336 self.eval_bridge(&code, params.webview_label.as_deref())
3337 .await
3338 }
3339 LogsAction::Ipc => {
3340 let wait = params.wait_for_capture.unwrap_or(false);
3341 let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
3342 if wait {
3343 let inner =
3344 trimmed_log_js(&format!("window.__VICTAURI__.getIpcLog({limit})"), limit);
3345 let code = format!(
3346 r"return (async function() {{
3347 await window.__VICTAURI__.waitForIpcComplete(500);
3348 return (function() {{ {inner} }})();
3349 }})()"
3350 );
3351 let timeout = std::time::Duration::from_millis(5000);
3352 match self
3353 .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
3354 .await
3355 {
3356 Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
3357 Err(e) => tool_error(e),
3358 }
3359 } else {
3360 let code =
3361 trimmed_log_js(&format!("window.__VICTAURI__?.getIpcLog({limit})"), limit);
3362 self.eval_bridge(&code, params.webview_label.as_deref())
3363 .await
3364 }
3365 }
3366 LogsAction::Navigation => {
3367 let code = if let Some(limit) = params.limit {
3368 format!(
3369 "return (window.__VICTAURI__?.getNavigationLog() || []).slice(-{limit})"
3370 )
3371 } else {
3372 "return window.__VICTAURI__?.getNavigationLog()".to_string()
3373 };
3374 self.eval_bridge(&code, params.webview_label.as_deref())
3375 .await
3376 }
3377 LogsAction::Dialogs => {
3378 let code = if let Some(limit) = params.limit {
3379 format!("return (window.__VICTAURI__?.getDialogLog() || []).slice(-{limit})")
3380 } else {
3381 "return window.__VICTAURI__?.getDialogLog()".to_string()
3382 };
3383 self.eval_bridge(&code, params.webview_label.as_deref())
3384 .await
3385 }
3386 LogsAction::Events => {
3387 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
3388 let base = if since_arg.is_empty() {
3389 "window.__VICTAURI__?.getEventStream()".to_string()
3390 } else {
3391 format!("window.__VICTAURI__?.getEventStream({since_arg})")
3392 };
3393 let code = if let Some(limit) = params.limit {
3394 format!("return ({base} || []).slice(-{limit})")
3395 } else {
3396 format!("return {base}")
3397 };
3398 self.eval_bridge(&code, params.webview_label.as_deref())
3399 .await
3400 }
3401 LogsAction::SlowIpc => {
3402 let Some(threshold) = params.threshold_ms else {
3403 return missing_param("threshold_ms", "slow_ipc");
3404 };
3405 let limit = params.limit.unwrap_or(20);
3406 let code = slow_ipc_js(threshold, limit);
3407 self.eval_bridge(&code, params.webview_label.as_deref())
3408 .await
3409 }
3410 LogsAction::Backend => self.logs_backend(¶ms),
3411 LogsAction::BackendDigest => self.logs_backend_digest(¶ms),
3412 LogsAction::Stdout => self.logs_stdout(¶ms),
3413 LogsAction::Clear => {
3414 if !self.state.privacy.is_tool_enabled("logs.clear") {
3418 return tool_disabled("logs.clear");
3419 }
3420 let code = format!(
3421 "return (function(){{ var b = {}; if (!b) return {{ ok:false, error:'bridge unavailable' }}; b.clearIpcLog(); b.clearNetworkLog(); return {{ ok:true, cleared:['ipc','network'] }}; }})()",
3422 crate::js_bridge::agent_ops_js()
3423 );
3424 self.eval_bridge(&code, params.webview_label.as_deref())
3425 .await
3426 }
3427 }
3428 }
3429
3430 #[tool(
3433 description = "Deep backend introspection — command profiling, IPC contract testing, \
3434 coverage, startup timing, capability auditing, database diagnostics, process \
3435 enumeration, and event bus monitoring. \
3436 These features exploit Victauri's position inside the Rust process.\n\n\
3437 Actions:\n\
3438 - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n\
3439 - `coverage`: Which registered commands have been called during this session.\n\
3440 - `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\
3441 - `contract_record`: Record a command's response shape as a baseline (requires `command`).\n\
3442 - `contract_check`: Check all recorded contracts for schema drift.\n\
3443 - `contract_list`: List all recorded contract baselines.\n\
3444 - `contract_clear`: Clear all recorded contract baselines.\n\
3445 - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
3446 - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
3447 - `db_health`: Read-only SQLite diagnostics: journal mode, page stats, freelist, \
3448 per-table row counts, and SQLite `quick_check` — each phase budgeted; a phase that \
3449 runs out is reported (`row_counts_complete`, `integrity_check: \"not completed…\"`) \
3450 instead of failing.\n\
3451 - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
3452 - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
3453 - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
3454 - `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\
3455 - `event_bus_clear`: Clear the event bus capture buffer.",
3456 annotations(
3457 read_only_hint = false,
3458 destructive_hint = true,
3459 idempotent_hint = false,
3460 open_world_hint = false
3461 )
3462 )]
3463 async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
3464 if !self.state.privacy.is_tool_enabled("introspect") {
3465 return tool_disabled("introspect");
3466 }
3467
3468 match params.action {
3469 IntrospectAction::CommandTimings => {
3470 let mut stats = self.state.command_timings.all_stats();
3471 let driven_count = stats.len();
3472 if let Some(threshold) = params.slow_threshold_ms {
3473 stats.retain(|s| s.avg_ms >= threshold);
3474 }
3475
3476 let code = ipc_timing_projection_js(None);
3483 let mut ipc_traffic = match self
3484 .eval_with_return(&code, params.webview_label.as_deref())
3485 .await
3486 {
3487 Ok(json_str) => page_json::parse_page_json::<Vec<serde_json::Value>>(&json_str)
3488 .map(|entries| ipc_timing_stats(&entries))
3489 .unwrap_or_default(),
3490 Err(_) => Vec::new(),
3491 };
3492 if let Some(threshold) = params.slow_threshold_ms {
3493 ipc_traffic.retain(|s| {
3494 s.get("avg_ms")
3495 .and_then(serde_json::Value::as_f64)
3496 .is_some_and(|a| a >= threshold)
3497 });
3498 }
3499
3500 let ipc_capture = self
3501 .ipc_capture_status(params.webview_label.as_deref())
3502 .await;
3503 let result = serde_json::json!({
3504 "commands": stats,
3505 "total_commands_profiled": driven_count,
3506 "ipc_capture": ipc_capture,
3507 "saturated": driven_count >= COMMAND_TIMINGS_CAP,
3510 "ipc_traffic": ipc_traffic,
3511 "ipc_commands_observed": ipc_traffic.len(),
3512 "slow_threshold_ms": params.slow_threshold_ms,
3513 "note": "`commands` profiles ONLY commands you drove through Victauri's \
3514 invoke_command tool (often empty on a live app). `ipc_traffic` \
3515 profiles the app's REAL frontend IPC, derived from the live IPC \
3516 log (per-command call_count + min/max/avg/p95 latency) — that is \
3517 the one reflecting actual usage.",
3518 });
3519 json_result(&result)
3520 }
3521 IntrospectAction::Coverage => {
3522 let registered: Vec<String> = self
3523 .state
3524 .registry
3525 .list()
3526 .iter()
3527 .map(|c| c.name.clone())
3528 .collect();
3529
3530 let code = ghost_ipc_projection_js(None);
3535 let (invoked, ipc_calls_observed): (std::collections::HashSet<String>, usize) =
3536 match self
3537 .eval_with_return(&code, params.webview_label.as_deref())
3538 .await
3539 {
3540 Ok(json_str) => {
3541 match page_json::parse_page_json::<Vec<String>>(&json_str) {
3542 Ok(names) => {
3543 let count = names.len();
3544 (names.into_iter().collect(), count)
3545 }
3546 Err(_) => (std::collections::HashSet::new(), 0),
3547 }
3548 }
3549 Err(_) => (std::collections::HashSet::new(), 0),
3550 };
3551
3552 let uncovered: Vec<&String> = registered
3553 .iter()
3554 .filter(|cmd| !invoked.contains(cmd.as_str()))
3555 .collect();
3556
3557 let coverage_pct = if registered.is_empty() {
3558 100.0
3559 } else {
3560 let covered = registered.len() - uncovered.len();
3561 (covered as f64 / registered.len() as f64) * 100.0
3562 };
3563
3564 let note = if registered.is_empty() {
3565 Some(
3566 "The introspection registry is empty (the app does not use \
3567 #[inspectable]/register_command_names), so coverage_pct is a \
3568 placeholder 100%. `invoked_not_registered` still lists the real \
3569 commands seen on the live IPC log — use it to inventory actual \
3570 traffic.",
3571 )
3572 } else if ipc_calls_observed == 0 {
3573 Some(
3574 "No IPC calls were observed on the live log. If the app is actively \
3575 making calls, confirm the target webview and check `ipc_capture`: \
3576 a `capture` other than `full` means the IPC picture is partial.",
3577 )
3578 } else {
3579 None
3580 };
3581 let ipc_capture = self
3582 .ipc_capture_status(params.webview_label.as_deref())
3583 .await;
3584
3585 let result = serde_json::json!({
3586 "ipc_capture": ipc_capture,
3587 "registered_commands": registered.len(),
3588 "invoked_commands": invoked.len(),
3589 "ipc_calls_observed": ipc_calls_observed,
3590 "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
3591 "uncovered": uncovered,
3592 "invoked_not_registered": invoked.iter()
3593 .filter(|cmd| !registered.contains(cmd))
3594 .collect::<Vec<_>>(),
3595 "note": note,
3596 });
3597 json_result(&result)
3598 }
3599 IntrospectAction::CommandCatalog => {
3600 let code = ipc_catalog_projection_js();
3607 let ipc_entries: Vec<serde_json::Value> = match self
3608 .eval_with_return(&code, params.webview_label.as_deref())
3609 .await
3610 {
3611 Ok(json_str) => page_json::parse_page_json(&json_str).unwrap_or_default(),
3612 Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
3613 };
3614
3615 let registry = self.state.registry.list();
3616 let catalog = merge_command_catalog(&ipc_entries, ®istry);
3617 let observed = catalog
3618 .iter()
3619 .filter(|c| {
3620 c.get("observed")
3621 .and_then(serde_json::Value::as_bool)
3622 .unwrap_or(false)
3623 })
3624 .count();
3625 let ipc_capture = self
3626 .ipc_capture_status(params.webview_label.as_deref())
3627 .await;
3628
3629 let result = serde_json::json!({
3630 "ipc_capture": ipc_capture,
3631 "catalog": catalog,
3632 "observed_count": observed,
3633 "registered_count": registry.len(),
3634 "total": catalog.len(),
3635 "note": "`arg_shape`/`result_shape` are STRUCTURES inferred from the live \
3636 IPC log (keys + value types, not values) — how to CALL each command \
3637 and what it RETURNS, even for apps that don't use #[inspectable]. \
3638 `observed:false` means the command is in the registry but hasn't \
3639 been seen on the wire this session (drive the app's UI to populate \
3640 its shape). `declared_*` fields, when present, come from \
3641 #[inspectable] and are authoritative over the inferred shape.",
3642 });
3643 json_result(&result)
3644 }
3645 IntrospectAction::ContractRecord => {
3646 let Some(command) = params.command else {
3647 return missing_param("command", "contract_record");
3648 };
3649 if !self.state.privacy.is_invoke_allowed(&command)
3652 || !self.state.privacy.is_command_allowed(&command)
3653 {
3654 return tool_error(format!(
3655 "command '{command}' is blocked by privacy configuration"
3656 ));
3657 }
3658 let args_json = params.args.unwrap_or(serde_json::json!({}));
3659 let args_str =
3660 serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
3661 let code = format!(
3662 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
3663 js_string(&command)
3664 );
3665 match self
3666 .eval_with_return(&code, params.webview_label.as_deref())
3667 .await
3668 {
3669 Ok(result_str) => {
3670 let value: serde_json::Value = serde_json::from_str(&result_str)
3671 .unwrap_or(serde_json::Value::String(result_str.clone()));
3672 let shape = crate::introspection::JsonShape::from_value(&value);
3673 let sample = if result_str.len() > 4096 {
3674 format!(
3675 "{}...(truncated)",
3676 truncate_at_char_boundary(&result_str, 4096)
3677 )
3678 } else {
3679 result_str
3680 };
3681 let baseline = crate::introspection::ContractBaseline {
3682 command: command.clone(),
3683 args: args_json,
3684 shape: shape.clone(),
3685 sample,
3686 recorded_at: chrono_now(),
3687 };
3688 self.state.contract_store.record(baseline);
3689 let result = serde_json::json!({
3690 "recorded": true,
3691 "command": command,
3692 "shape_type": shape.type_name(),
3693 });
3694 json_result(&result)
3695 }
3696 Err(e) => tool_error(format!(
3697 "failed to invoke '{command}' for contract recording: {e}"
3698 )),
3699 }
3700 }
3701 IntrospectAction::ContractCheck => {
3702 let baselines = self.state.contract_store.all();
3703 if baselines.is_empty() {
3704 return json_result(&serde_json::json!({
3705 "checked": 0,
3706 "message": "no contract baselines recorded — use contract_record first",
3707 }));
3708 }
3709 let mut results = Vec::new();
3710 for baseline in &baselines {
3711 if !self.state.privacy.is_invoke_allowed(&baseline.command)
3714 || !self.state.privacy.is_command_allowed(&baseline.command)
3715 {
3716 continue;
3717 }
3718 let args_str =
3719 serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
3720 let code = format!(
3721 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
3722 js_string(&baseline.command)
3723 );
3724 match self
3725 .eval_with_return(&code, params.webview_label.as_deref())
3726 .await
3727 {
3728 Ok(result_str) => {
3729 let value: serde_json::Value = serde_json::from_str(&result_str)
3730 .unwrap_or(serde_json::Value::String(result_str));
3731 let current_shape = crate::introspection::JsonShape::from_value(&value);
3732 let drift = crate::introspection::diff_shapes(
3733 &baseline.shape,
3734 ¤t_shape,
3735 &baseline.command,
3736 );
3737 results.push(drift);
3738 }
3739 Err(e) => {
3740 results.push(crate::introspection::ContractDrift {
3741 command: baseline.command.clone(),
3742 new_fields: Vec::new(),
3743 removed_fields: Vec::new(),
3744 type_changes: Vec::new(),
3745 shape_matches: false,
3746 });
3747 tracing::warn!(
3748 command = %baseline.command,
3749 error = %e,
3750 "contract check invocation failed"
3751 );
3752 }
3753 }
3754 }
3755 let passing = results.iter().filter(|r| r.shape_matches).count();
3756 let result = serde_json::json!({
3757 "checked": results.len(),
3758 "passing": passing,
3759 "failing": results.len() - passing,
3760 "contracts": results,
3761 });
3762 json_result(&result)
3763 }
3764 IntrospectAction::ContractList => {
3765 let baselines = self.state.contract_store.all();
3766 let result = serde_json::json!({
3767 "count": baselines.len(),
3768 "baselines": baselines.iter().map(|b| serde_json::json!({
3769 "command": b.command,
3770 "shape_type": b.shape.type_name(),
3771 "recorded_at": b.recorded_at,
3772 })).collect::<Vec<_>>(),
3773 });
3774 json_result(&result)
3775 }
3776 IntrospectAction::ContractClear => {
3777 let cleared = self.state.contract_store.clear();
3778 json_result(&serde_json::json!({
3779 "cleared": cleared,
3780 }))
3781 }
3782 IntrospectAction::StartupTiming => {
3783 let phases = self.state.startup_timeline.report();
3784 let result = serde_json::json!({
3785 "phases": phases,
3786 "total_ms": self.state.startup_timeline.total_ms(),
3787 "uptime_secs": self.state.started_at.elapsed().as_secs(),
3788 });
3789 json_result(&result)
3790 }
3791 IntrospectAction::Capabilities => {
3792 let config = self.bridge.tauri_config();
3793 let (live_windows, live_windows_error) = match self.bridge.try_list_window_labels()
3795 {
3796 Ok(labels) => (Some(labels), None),
3797 Err(e) => (None, Some(ui_busy(&e))),
3798 };
3799
3800 let result = serde_json::json!({
3801 "app": {
3802 "identifier": config.get("identifier"),
3803 "product_name": config.get("product_name"),
3804 "version": config.get("version"),
3805 },
3806 "security": config.get("security"),
3807 "configured_windows": config.get("windows"),
3808 "live_windows": live_windows,
3809 "live_windows_error": live_windows_error,
3810 "configured_plugins": config.get("plugins"),
3811 "victauri": {
3812 "registered_commands": self.state.registry.list().len(),
3813 "redaction_enabled": self.state.privacy.redaction_enabled,
3814 "privacy_profile": format!("{:?}", self.state.privacy.profile),
3815 "disabled_tools": &self.state.privacy.disabled_tools,
3816 },
3817 });
3818 json_result(&result)
3819 }
3820 #[allow(unused_variables)]
3821 IntrospectAction::DbHealth => {
3822 #[cfg(feature = "sqlite")]
3823 {
3824 let db_path = params.db_path.clone();
3825 match self.run_db_health(db_path.as_deref()).await {
3826 Ok(health) => json_result(&health),
3827 Err(e) => tool_error(format!("db_health failed: {e}")),
3828 }
3829 }
3830 #[cfg(not(feature = "sqlite"))]
3831 {
3832 tool_error("SQLite support not compiled in — enable the `sqlite` feature")
3833 }
3834 }
3835 IntrospectAction::PluginState => {
3836 let recording_active = self.state.recorder.is_recording();
3837 let recording_events = self.state.recorder.event_count();
3838 let result = serde_json::json!({
3839 "event_log": {
3840 "size": self.state.event_log.len(),
3841 "capacity": self.state.event_log.capacity(),
3842 },
3843 "registry": {
3844 "commands_registered": self.state.registry.list().len(),
3845 },
3846 "recording": {
3847 "active": recording_active,
3848 "events_captured": recording_events,
3849 },
3850 "faults": {
3851 "active_rules": self.state.fault_registry.list().len(),
3852 },
3853 "contracts": {
3854 "baselines_recorded": self.state.contract_store.all().len(),
3855 },
3856 "timings": {
3857 "commands_profiled": self.state.command_timings.all_stats().len(),
3858 },
3859 "event_bus": {
3860 "captured_events": self.state.event_bus.len(),
3861 },
3862 "tasks": {
3863 "total": self.state.task_tracker.list().len(),
3864 "active": self.state.task_tracker.active_count(),
3865 },
3866 "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
3867 "uptime_secs": self.state.started_at.elapsed().as_secs(),
3868 "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
3869 });
3870 json_result(&result)
3871 }
3872 IntrospectAction::Processes => {
3873 let pid = std::process::id();
3874 let uptime = self.state.started_at.elapsed();
3875 let children = crate::introspection::enumerate_child_processes();
3876 let host_memory = crate::memory::current_stats();
3877
3878 let result = serde_json::json!({
3879 "host": {
3880 "pid": pid,
3881 "uptime_secs": uptime.as_secs(),
3882 "platform": std::env::consts::OS,
3883 "arch": std::env::consts::ARCH,
3884 "memory": host_memory,
3885 },
3886 "children": children.iter().map(|c| serde_json::json!({
3887 "pid": c.pid,
3888 "name": c.name,
3889 "memory_bytes": c.memory_bytes,
3890 })).collect::<Vec<_>>(),
3891 "child_count": children.len(),
3892 "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
3893 });
3894 json_result(&result)
3895 }
3896 IntrospectAction::PluginTasks => {
3897 let tasks = self.state.task_tracker.list();
3898 let active = self.state.task_tracker.active_count();
3899 let result = serde_json::json!({
3900 "total": tasks.len(),
3901 "active": active,
3902 "finished": tasks.len() - active,
3903 "tasks": tasks,
3904 });
3905 json_result(&result)
3906 }
3907 IntrospectAction::EventBus => {
3908 let opts = params.args.as_ref();
3914 let limit = opts
3915 .and_then(|a| a.get("limit"))
3916 .and_then(serde_json::Value::as_u64)
3917 .and_then(|n| usize::try_from(n).ok())
3918 .unwrap_or(100);
3919 let since_ms = opts
3920 .and_then(|a| a.get("since_ms"))
3921 .and_then(serde_json::Value::as_u64);
3922 let cutoff = since_ms.map(|ms| bounded::ms_ago(chrono::Utc::now(), ms));
3923
3924 let all_tauri = self.state.event_bus.events();
3925 let tauri_total = all_tauri.len();
3926 let tauri_matched: Vec<_> = all_tauri
3927 .into_iter()
3928 .filter(|e| match cutoff {
3929 Some(cut) => chrono::DateTime::parse_from_rfc3339(&e.timestamp)
3930 .map_or(true, |t| t.with_timezone(&chrono::Utc) >= cut),
3931 None => true,
3932 })
3933 .collect();
3934 let tauri_matched_count = tauri_matched.len();
3935 let tauri_events: Vec<_> = tauri_matched.into_iter().rev().take(limit).collect();
3936
3937 let all_app: Vec<_> = self
3941 .state
3942 .event_log
3943 .snapshot()
3944 .into_iter()
3945 .filter(|e| !e.is_internal())
3946 .collect();
3947 let app_total = all_app.len();
3948 let app_matched: Vec<_> = match cutoff {
3949 Some(cut) => all_app
3950 .into_iter()
3951 .filter(|e| e.timestamp() >= cut)
3952 .collect(),
3953 None => all_app,
3954 };
3955 let app_matched_count = app_matched.len();
3956 let app_events: Vec<_> = app_matched.into_iter().rev().take(limit).collect();
3957
3958 let result = serde_json::json!({
3959 "limit": limit,
3960 "since_ms": since_ms,
3961 "tauri_events": {
3962 "count": tauri_total,
3963 "matched": tauri_matched_count,
3964 "returned": tauri_events.len(),
3965 "truncated": tauri_matched_count > tauri_events.len(),
3966 "events": tauri_events,
3967 },
3968 "app_events": {
3969 "count": app_total,
3970 "matched": app_matched_count,
3971 "returned": app_events.len(),
3972 "truncated": app_matched_count > app_events.len(),
3973 "capacity": self.state.event_log.capacity(),
3974 "events": app_events,
3975 },
3976 });
3977 json_result(&result)
3978 }
3979 IntrospectAction::EventBusClear => {
3980 let tauri_cleared = self.state.event_bus.clear();
3981 self.state.event_log.clear();
3982 json_result(&serde_json::json!({
3983 "tauri_events_cleared": tauri_cleared,
3984 "app_events_cleared": true,
3985 }))
3986 }
3987 }
3988 }
3989
3990 #[tool(
3993 description = "Probe a backend command handler under failure by faulting it for chaos engineering. \
3994 Simulate slow commands, backend errors, dropped responses, and corrupted data. \
3995 SCOPE: faults apply ONLY to commands you run via this server's `invoke_command` tool — \
3996 they do NOT touch the app's real user-driven IPC (window.__TAURI_INTERNALS__.invoke). Use \
3997 this to test a handler's error path when YOU drive it. To make a call the user's click \
3998 triggers fail or slow down, use a `route` rule on its IPC URL instead (fetch transport \
3999 only).\n\n\
4000 Actions:\n\
4001 - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms` \
4002 (max 120000), `error_message`, `max_triggers`. `delay` sleeps then runs the command; \
4003 `error` returns the error without running it; `drop` returns `{}` without running \
4004 it; `corrupt` runs it and replaces the response with a fixed \
4005 `{\"__corrupted\":true,…}` marker. Rules expire after 15 minutes.\n\
4006 - `list`: List all active fault injection rules.\n\
4007 - `clear`: Remove a specific fault rule (requires `command`).\n\
4008 - `clear_all`: Remove all fault rules.",
4009 annotations(
4010 read_only_hint = false,
4011 destructive_hint = true,
4012 idempotent_hint = false,
4013 open_world_hint = false
4014 )
4015 )]
4016 async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
4017 if !self.state.privacy.is_tool_enabled("fault") {
4018 return tool_disabled("fault");
4019 }
4020
4021 match params.action {
4022 FaultAction::Inject => {
4023 let Some(command) = params.command else {
4024 return missing_param("command", "inject");
4025 };
4026 let Some(fault_kind) = params.fault_type else {
4027 return missing_param("fault_type", "inject");
4028 };
4029 let fault_type = match fault_kind {
4030 FaultKind::Delay => {
4031 let delay_ms = params.delay_ms.unwrap_or(1000);
4032 if delay_ms > MAX_FAULT_DELAY_MS {
4035 return tool_error(format!(
4036 "delay_ms {delay_ms} exceeds the maximum of {MAX_FAULT_DELAY_MS} ms"
4037 ));
4038 }
4039 crate::introspection::FaultType::Delay { delay_ms }
4040 }
4041 FaultKind::Error => {
4042 let message = params
4043 .error_message
4044 .unwrap_or_else(|| "injected fault".to_string());
4045 crate::introspection::FaultType::Error { message }
4046 }
4047 FaultKind::Drop => crate::introspection::FaultType::Drop,
4048 FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
4049 };
4050 let config = crate::introspection::FaultConfig {
4051 command: command.clone(),
4052 fault_type: fault_type.clone(),
4053 trigger_count: 0,
4054 max_triggers: params.max_triggers.unwrap_or(0),
4055 created_at: std::time::Instant::now(),
4056 };
4057 self.state.fault_registry.inject(config);
4058 let result = serde_json::json!({
4059 "injected": true,
4060 "command": command,
4061 "fault_type": fault_type,
4062 "max_triggers": params.max_triggers.unwrap_or(0),
4063 });
4064 json_result(&result)
4065 }
4066 FaultAction::List => {
4067 let faults = self.state.fault_registry.list();
4068 let result = serde_json::json!({
4069 "count": faults.len(),
4070 "faults": faults.iter().map(|f| serde_json::json!({
4071 "command": f.command,
4072 "fault_type": f.fault_type,
4073 "trigger_count": f.trigger_count,
4074 "max_triggers": f.max_triggers,
4075 })).collect::<Vec<_>>(),
4076 });
4077 json_result(&result)
4078 }
4079 FaultAction::Clear => {
4080 let Some(command) = params.command else {
4081 return missing_param("command", "clear");
4082 };
4083 let removed = self.state.fault_registry.clear(&command);
4084 json_result(&serde_json::json!({
4085 "removed": removed,
4086 "command": command,
4087 }))
4088 }
4089 FaultAction::ClearAll => {
4090 let removed = self.state.fault_registry.clear_all();
4091 json_result(&serde_json::json!({
4092 "removed": removed,
4093 }))
4094 }
4095 }
4096 }
4097
4098 #[tool(
4101 description = "Correlate recent activity across all layers into a coherent narrative. \
4102 CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
4103 + window events across the Rust backend and webview simultaneously.\n\n\
4104 Actions:\n\
4105 - `summary`: High-level activity summary for the last N seconds (default 30). \
4106 Counts IPC calls, DOM mutations, console entries, state changes (incl. network \
4107 requests), errors. With no recording active it reads the window's live event \
4108 stream (`webview_label`, default main).\n\
4109 - `last_action`: Correlate the most recent burst of events into a causal timeline \
4110 (e.g. 'IPC call → DOM update → console.log').\n\
4111 - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
4112 annotations(
4113 read_only_hint = true,
4114 destructive_hint = false,
4115 idempotent_hint = true,
4116 open_world_hint = false
4117 )
4118 )]
4119 async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
4120 if !self.state.privacy.is_tool_enabled("explain") {
4121 return tool_disabled("explain");
4122 }
4123
4124 match params.action {
4125 ExplainAction::Summary => {
4126 let secs = params.seconds.unwrap_or(30);
4127 let since = bounded::secs_ago(chrono::Utc::now(), secs);
4128 let events = self
4129 .explain_events(since, params.webview_label.as_deref())
4130 .await;
4131
4132 let mut ipc_count = 0u64;
4133 let mut dom_mutations = 0u64;
4134 let mut state_changes = 0u64;
4135 let mut console_count = 0u64;
4136 let mut window_events = 0u64;
4137 let mut interactions = 0u64;
4138 let mut top_commands: HashMap<String, u64> = HashMap::new();
4139 let mut errors: Vec<String> = Vec::new();
4140
4141 for event in &events {
4142 match event {
4143 victauri_core::AppEvent::Ipc(call) => {
4144 ipc_count += 1;
4145 *top_commands.entry(call.command.clone()).or_insert(0) += 1;
4146 if let victauri_core::IpcResult::Err(e) = &call.result {
4147 errors.push(format!("IPC {}: {e}", call.command));
4148 }
4149 }
4150 victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
4151 dom_mutations += u64::from(*mutation_count)
4152 }
4153 victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
4154 victauri_core::AppEvent::Console { level, message, .. } => {
4155 console_count += 1;
4156 if level == "error" {
4157 errors.push(format!("console.error: {message}"));
4158 }
4159 }
4160 victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
4161 victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
4162 _ => {}
4163 }
4164 }
4165
4166 let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
4167 sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
4168 let top: Vec<_> = sorted_cmds.iter().take(5).collect();
4169
4170 let narrative = format!(
4171 "{ipc_count} IPC call{} in the last {secs}s{}. \
4172 {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
4173 {console_count} console message{}, {window_events} window event{}. {}.",
4174 if ipc_count == 1 { "" } else { "s" },
4175 if top.is_empty() {
4176 String::new()
4177 } else {
4178 format!(
4179 ", dominated by {}",
4180 top.iter()
4181 .map(|(cmd, n)| format!("{cmd} ({n}x)"))
4182 .collect::<Vec<_>>()
4183 .join(", ")
4184 )
4185 },
4186 if dom_mutations == 1 { "" } else { "s" },
4187 if interactions == 1 { "" } else { "s" },
4188 if console_count == 1 { "" } else { "s" },
4189 if window_events == 1 { "" } else { "s" },
4190 if errors.is_empty() {
4191 "No errors".to_string()
4192 } else {
4193 format!(
4194 "{} error{}",
4195 errors.len(),
4196 if errors.len() == 1 { "" } else { "s" }
4197 )
4198 },
4199 );
4200
4201 let result = serde_json::json!({
4202 "time_window_secs": secs,
4203 "total_events": events.len(),
4204 "ipc_calls": ipc_count,
4205 "dom_mutations": dom_mutations,
4206 "state_changes": state_changes,
4207 "console_messages": console_count,
4208 "window_events": window_events,
4209 "interactions": interactions,
4210 "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
4211 serde_json::json!({"command": cmd, "count": n})
4212 }).collect::<Vec<_>>(),
4213 "errors": errors,
4214 "narrative": narrative,
4215 });
4216 json_result(&result)
4217 }
4218 ExplainAction::LastAction => {
4219 let secs = params.seconds.unwrap_or(5);
4220 let since = bounded::secs_ago(chrono::Utc::now(), secs);
4221 let events = self
4222 .explain_events(since, params.webview_label.as_deref())
4223 .await;
4224
4225 let timeline: Vec<serde_json::Value> = events
4226 .iter()
4227 .filter(|e| !e.is_internal())
4228 .map(|event| match event {
4229 victauri_core::AppEvent::Ipc(call) => serde_json::json!({
4230 "time": call.timestamp.to_rfc3339_opts(
4231 chrono::SecondsFormat::Millis, true
4232 ),
4233 "type": "ipc",
4234 "detail": format!(
4235 "{} {} ({}ms)",
4236 call.command,
4237 call.result,
4238 call.duration_ms.unwrap_or(0)
4239 ),
4240 }),
4241 victauri_core::AppEvent::DomMutation {
4242 timestamp,
4243 mutation_count,
4244 webview_label,
4245 ..
4246 } => serde_json::json!({
4247 "time": timestamp.to_rfc3339_opts(
4248 chrono::SecondsFormat::Millis, true
4249 ),
4250 "type": "dom_mutation",
4251 "detail": format!(
4252 "{mutation_count} element{} updated in {webview_label}",
4253 if *mutation_count == 1 { "" } else { "s" }
4254 ),
4255 }),
4256 victauri_core::AppEvent::DomInteraction {
4257 timestamp,
4258 action,
4259 selector,
4260 ..
4261 } => serde_json::json!({
4262 "time": timestamp.to_rfc3339_opts(
4263 chrono::SecondsFormat::Millis, true
4264 ),
4265 "type": "interaction",
4266 "detail": format!("{action} on {selector}"),
4267 }),
4268 victauri_core::AppEvent::StateChange {
4269 timestamp,
4270 key,
4271 caused_by,
4272 ..
4273 } => serde_json::json!({
4274 "time": timestamp.to_rfc3339_opts(
4275 chrono::SecondsFormat::Millis, true
4276 ),
4277 "type": "state_change",
4278 "detail": format!(
4279 "{key} changed{}",
4280 caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
4281 ),
4282 }),
4283 victauri_core::AppEvent::Console {
4284 timestamp,
4285 level,
4286 message,
4287 ..
4288 } => serde_json::json!({
4289 "time": timestamp.to_rfc3339_opts(
4290 chrono::SecondsFormat::Millis, true
4291 ),
4292 "type": "console",
4293 "detail": format!("console.{level}: {message}"),
4294 }),
4295 victauri_core::AppEvent::WindowEvent {
4296 timestamp,
4297 label,
4298 event,
4299 ..
4300 } => serde_json::json!({
4301 "time": timestamp.to_rfc3339_opts(
4302 chrono::SecondsFormat::Millis, true
4303 ),
4304 "type": "window_event",
4305 "detail": format!("{event} on window '{label}'"),
4306 }),
4307 _ => serde_json::json!({
4308 "time": event.timestamp().to_rfc3339_opts(
4309 chrono::SecondsFormat::Millis, true
4310 ),
4311 "type": "other",
4312 "detail": "unknown event type",
4313 }),
4314 })
4315 .collect();
4316
4317 let narrative = if timeline.is_empty() {
4318 format!("No activity in the last {secs}s.")
4319 } else {
4320 let parts: Vec<String> = timeline
4321 .iter()
4322 .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
4323 .map(String::from)
4324 .collect();
4325 parts.join(" → ")
4326 };
4327
4328 let result = serde_json::json!({
4329 "time_window_secs": secs,
4330 "event_count": timeline.len(),
4331 "timeline": timeline,
4332 "narrative": narrative,
4333 });
4334 json_result(&result)
4335 }
4336 ExplainAction::Diff => {
4337 let secs = params.seconds.unwrap_or(10);
4338 let since = bounded::secs_ago(chrono::Utc::now(), secs);
4339 let events = self
4340 .explain_events(since, params.webview_label.as_deref())
4341 .await;
4342
4343 let mut ipc_commands: Vec<String> = Vec::new();
4344 let mut dom_changes = 0u64;
4345 let mut error_count = 0u64;
4346 let mut interaction_count = 0u64;
4347 let mut console_messages = 0u64;
4348
4349 for event in &events {
4350 if event.is_internal() {
4351 continue;
4352 }
4353 match event {
4354 victauri_core::AppEvent::Ipc(call) => {
4355 ipc_commands.push(call.command.clone());
4356 if matches!(call.result, victauri_core::IpcResult::Err(_)) {
4357 error_count += 1;
4358 }
4359 }
4360 victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
4361 dom_changes += u64::from(*mutation_count)
4362 }
4363 victauri_core::AppEvent::DomInteraction { .. } => {
4364 interaction_count += 1;
4365 }
4366 victauri_core::AppEvent::Console { level, .. } => {
4367 console_messages += 1;
4368 if level == "error" {
4369 error_count += 1;
4370 }
4371 }
4372 _ => {}
4373 }
4374 }
4375
4376 let ipc_calls_made = ipc_commands.len();
4380 let mut seen = HashSet::new();
4381 ipc_commands.retain(|c| seen.insert(c.clone()));
4382
4383 let result = serde_json::json!({
4384 "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
4385 "time_window_secs": secs,
4386 "total_events": events.len(),
4387 "ipc_calls_made": ipc_calls_made,
4388 "unique_commands": ipc_commands,
4389 "dom_elements_changed": dom_changes,
4390 "interactions": interaction_count,
4391 "console_messages": console_messages,
4392 "errors": error_count,
4393 });
4394 json_result(&result)
4395 }
4396 }
4397 }
4398}
4399
4400impl VictauriMcpHandler {
4401 pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
4403 Self {
4404 state,
4405 bridge,
4406 subscriptions: Arc::new(Mutex::new(HashSet::new())),
4407 bridge_checked: Arc::new(AtomicBool::new(false)),
4408 timed_out_labels: Arc::new(Mutex::new(HashSet::new())),
4409 probe_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_PROBES)),
4410 file_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_FILE_READS)),
4411 probe_timeout: PROBE_TIMEOUT,
4412 file_read_timeout: READ_APP_FILE_TIMEOUT,
4413 }
4414 }
4415
4416 pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
4417 self.state.privacy.is_tool_enabled(name)
4418 }
4419
4420 pub(crate) async fn execute_tool(
4421 &self,
4422 name: &str,
4423 args: serde_json::Value,
4424 ) -> Result<CallToolResult, rest::ToolCallError> {
4425 if self.state.privacy.disabled_tools.contains(name) {
4430 return Ok(tool_disabled(name));
4431 }
4432 let capability =
4433 authz::resolve_capability(name, &args).map_err(rest::ToolCallError::InvalidParams)?;
4434 if !self.state.privacy.is_call_allowed(name, &capability) {
4435 return Ok(tool_disabled(&capability));
4436 }
4437 self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
4438 let start = std::time::Instant::now();
4439 tracing::debug!(tool = %name, "REST tool invocation started");
4440
4441 let result = match bounded::CatchUnwind::new(self.dispatch_tool(name, args)).await {
4444 Ok(dispatched) => dispatched?,
4445 Err(panic) => {
4446 tracing::error!(tool = %name, "tool handler panicked: {panic}");
4447 tool_panicked(name, &panic)
4448 }
4449 };
4450
4451 let elapsed = start.elapsed();
4452 tracing::debug!(
4453 tool = %name,
4454 elapsed_ms = elapsed.as_millis() as u64,
4455 "REST tool invocation completed"
4456 );
4457
4458 if self.state.privacy.redaction_enabled {
4459 Ok(Self::redact_result(result, &self.state.privacy))
4460 } else {
4461 Ok(result)
4462 }
4463 }
4464
4465 async fn dispatch_tool(
4467 &self,
4468 name: &str,
4469 args: serde_json::Value,
4470 ) -> Result<CallToolResult, rest::ToolCallError> {
4471 let result = match name {
4472 "eval_js" => {
4473 let p: EvalJsParams = Self::parse_args(args)?;
4474 self.eval_js(Parameters(p)).await
4475 }
4476 "dom_snapshot" => {
4477 let p: SnapshotParams = Self::parse_args(args)?;
4478 self.dom_snapshot(Parameters(p)).await
4479 }
4480 "find_elements" => {
4481 let p: FindElementsParams = Self::parse_args(args)?;
4482 self.find_elements(Parameters(p)).await
4483 }
4484 "invoke_command" => {
4485 let p: InvokeCommandParams = Self::parse_args(args)?;
4486 self.invoke_command(Parameters(p)).await
4487 }
4488 "screenshot" => {
4489 let p: ScreenshotParams = Self::parse_args(args)?;
4490 self.screenshot(Parameters(p)).await
4491 }
4492 "verify_state" => {
4493 let p: VerifyStateParams = Self::parse_args(args)?;
4494 self.verify_state(Parameters(p)).await
4495 }
4496 "detect_ghost_commands" => {
4497 let p: GhostCommandParams = Self::parse_args(args)?;
4498 self.detect_ghost_commands(Parameters(p)).await
4499 }
4500 "check_ipc_integrity" => {
4501 let p: IpcIntegrityParams = Self::parse_args(args)?;
4502 self.check_ipc_integrity(Parameters(p)).await
4503 }
4504 "wait_for" => {
4505 let p: WaitForParams = Self::parse_args(args)?;
4506 self.wait_for(Parameters(p)).await
4507 }
4508 "assert_semantic" => {
4509 let p: SemanticAssertParams = Self::parse_args(args)?;
4510 self.assert_semantic(Parameters(p)).await
4511 }
4512 "resolve_command" => {
4513 let p: ResolveCommandParams = Self::parse_args(args)?;
4514 self.resolve_command(Parameters(p)).await
4515 }
4516 "get_registry" => {
4517 let p: RegistryParams = Self::parse_args(args)?;
4518 self.get_registry(Parameters(p)).await
4519 }
4520 "app_state" => {
4521 let p: AppStateParams = Self::parse_args(args)?;
4522 self.app_state(Parameters(p)).await
4523 }
4524 "get_memory_stats" => self.get_memory_stats().await,
4525 "get_plugin_info" => self.get_plugin_info().await,
4526 "get_diagnostics" => {
4527 let p: DiagnosticsParams = Self::parse_args(args)?;
4528 self.get_diagnostics(Parameters(p)).await
4529 }
4530 "app_info" => self.app_info().await,
4531 "list_app_dir" => {
4532 let p: ListAppDirParams = Self::parse_args(args)?;
4533 self.list_app_dir(Parameters(p)).await
4534 }
4535 "read_app_file" => {
4536 let p: ReadAppFileParams = Self::parse_args(args)?;
4537 self.read_app_file(Parameters(p)).await
4538 }
4539 "query_db" => {
4540 let p: QueryDbParams = Self::parse_args(args)?;
4541 self.query_db(Parameters(p)).await
4542 }
4543 "interact" => {
4544 let p: InteractParams = Self::parse_args(args)?;
4545 self.interact(Parameters(p)).await
4546 }
4547 "input" => {
4548 let p: InputParams = Self::parse_args(args)?;
4549 self.input(Parameters(p)).await
4550 }
4551 "window" => {
4552 let p: WindowParams = Self::parse_args(args)?;
4553 self.window(Parameters(p)).await
4554 }
4555 "storage" => {
4556 let p: StorageParams = Self::parse_args(args)?;
4557 self.storage(Parameters(p)).await
4558 }
4559 "navigate" => {
4560 let p: NavigateParams = Self::parse_args(args)?;
4561 self.navigate(Parameters(p)).await
4562 }
4563 "recording" => {
4564 let p: RecordingParams = Self::parse_args(args)?;
4565 self.recording(Parameters(p)).await
4566 }
4567 "inspect" => {
4568 let p: InspectParams = Self::parse_args(args)?;
4569 self.inspect(Parameters(p)).await
4570 }
4571 "css" => {
4572 let p: CssParams = Self::parse_args(args)?;
4573 self.css(Parameters(p)).await
4574 }
4575 "route" => {
4576 let p: RouteParams = Self::parse_args(args)?;
4577 self.route(Parameters(p)).await
4578 }
4579 "trace" => {
4580 let p: TraceParams = Self::parse_args(args)?;
4581 self.trace(Parameters(p)).await
4582 }
4583 "animation" => {
4584 let p: AnimationParams = Self::parse_args(args)?;
4585 self.animation(Parameters(p)).await
4586 }
4587 "logs" => {
4588 let p: LogsParams = Self::parse_args(args)?;
4589 self.logs(Parameters(p)).await
4590 }
4591 "introspect" => {
4592 let p: IntrospectParams = Self::parse_args(args)?;
4593 self.introspect(Parameters(p)).await
4594 }
4595 "fault" => {
4596 let p: FaultParams = Self::parse_args(args)?;
4597 self.fault(Parameters(p)).await
4598 }
4599 "explain" => {
4600 let p: ExplainParams = Self::parse_args(args)?;
4601 self.explain(Parameters(p)).await
4602 }
4603 _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
4604 };
4605 Ok(result)
4606 }
4607
4608 fn parse_args<T: serde::de::DeserializeOwned>(
4609 args: serde_json::Value,
4610 ) -> Result<T, rest::ToolCallError> {
4611 serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
4612 }
4613
4614 fn redact_result(
4615 mut result: CallToolResult,
4616 privacy: &crate::privacy::PrivacyConfig,
4617 ) -> CallToolResult {
4618 for item in &mut result.content {
4619 if let ContentBlock::Text(tc) = item {
4620 tc.text = privacy.redact_output(&tc.text);
4621 }
4622 }
4623 result
4624 }
4625
4626 async fn read_regular_file_bounded(
4632 &self,
4633 path: std::path::PathBuf,
4634 max_bytes: usize,
4635 ) -> Result<(Vec<u8>, usize, Option<u64>), String> {
4636 let Ok(slot) = Arc::clone(&self.file_slots).try_acquire_owned() else {
4637 return Err(format!(
4638 "file reads are busy ({MAX_CONCURRENT_FILE_READS} still running — a read \
4639 blocked on a pipe or device keeps running past its timeout). Retry shortly."
4640 ));
4641 };
4642 bounded::run_blocking_bounded(None, "file read", self.file_read_timeout, move || {
4643 let _slot = slot;
4644 read_regular_file(&path, max_bytes)
4645 })
4646 .await
4647 }
4648
4649 fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
4650 match dir.unwrap_or(AppDir::Data) {
4651 AppDir::Data => self.bridge.app_data_dir(),
4652 AppDir::Config => self.bridge.app_config_dir(),
4653 AppDir::Log => self.bridge.app_log_dir(),
4654 AppDir::LocalData => self.bridge.app_local_data_dir(),
4655 }
4656 }
4657
4658 fn lexical_safe(sub: &std::path::Path) -> Result<(), String> {
4666 use std::path::Component;
4667 if sub.is_absolute() {
4668 return Err("path traversal not allowed: absolute paths are rejected".to_string());
4669 }
4670 for component in sub.components() {
4671 match component {
4672 Component::ParentDir => {
4673 return Err("path traversal not allowed: '..' is rejected".to_string());
4674 }
4675 Component::Prefix(_) | Component::RootDir => {
4676 return Err(
4677 "path traversal not allowed: absolute paths are rejected".to_string()
4678 );
4679 }
4680 Component::CurDir | Component::Normal(_) => {}
4681 }
4682 }
4683 Ok(())
4684 }
4685
4686 fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
4687 let canon_base = std::fs::canonicalize(base)
4688 .map_err(|e| format!("cannot resolve base directory: {e}"))?;
4689 let canon_target = std::fs::canonicalize(target)
4690 .map_err(|e| format!("cannot resolve target path: {e}"))?;
4691 if !canon_target.starts_with(&canon_base) {
4692 return Err("path traversal not allowed".to_string());
4693 }
4694 Ok(())
4695 }
4696
4697 #[cfg(feature = "sqlite")]
4698 fn resolve_existing_db_path(
4699 roots: &[std::path::PathBuf],
4700 requested: &str,
4701 ) -> Result<std::path::PathBuf, String> {
4702 let candidate = std::path::Path::new(requested);
4703 let not_found = || {
4707 format!(
4708 "database not found (or it resolves outside the allowed directories): {requested}"
4709 )
4710 };
4711 if candidate.is_absolute() {
4712 if candidate
4715 .components()
4716 .any(|c| matches!(c, std::path::Component::ParentDir))
4717 {
4718 return Err("path traversal not allowed: '..' is rejected".to_string());
4719 }
4720 let normalized = lexically_normalize(candidate);
4725 let lexically_inside = roots.iter().any(|root| {
4726 normalized.starts_with(lexically_normalize(root))
4727 || std::fs::canonicalize(root)
4728 .is_ok_and(|canon_root| normalized.starts_with(canon_root))
4729 });
4730 if !lexically_inside {
4731 return Err(format!(
4732 "absolute path '{requested}' is not within an allowed directory; \
4733 register its parent via VictauriBuilder::db_search_paths"
4734 ));
4735 }
4736 if candidate.exists()
4737 && roots
4738 .iter()
4739 .any(|root| Self::safe_within(root, candidate).is_ok())
4740 {
4741 let canonical = std::fs::canonicalize(candidate)
4747 .map_err(|e| format!("cannot resolve database path: {e}"))?;
4748 return Ok(canonical);
4749 }
4750 return Err(not_found());
4751 }
4752
4753 Self::lexical_safe(candidate)?;
4754 for root in roots {
4755 let resolved = root.join(candidate);
4756 if resolved.exists() && Self::safe_within(root, &resolved).is_ok() {
4758 let canonical = std::fs::canonicalize(&resolved)
4763 .map_err(|e| format!("cannot resolve database path: {e}"))?;
4764 return Ok(canonical);
4765 }
4766 }
4767
4768 let roots = roots
4769 .iter()
4770 .map(|root| root.display().to_string())
4771 .collect::<Vec<_>>()
4772 .join(", ");
4773 Err(format!("{} (searched: {roots})", not_found()))
4774 }
4775
4776 async fn explain_events(
4782 &self,
4783 since: chrono::DateTime<chrono::Utc>,
4784 webview_label: Option<&str>,
4785 ) -> Vec<victauri_core::AppEvent> {
4786 if self.state.recorder.is_recording() {
4787 return self.state.event_log.since(since);
4788 }
4789 let code = format!(
4790 "return window.__VICTAURI__?.getEventStream({})",
4791 since.timestamp_millis()
4792 );
4793 let Ok(raw) = self.eval_with_return(&code, webview_label).await else {
4794 return self.state.event_log.since(since);
4795 };
4796 let label = webview_label.unwrap_or("main");
4797 let mut events: Vec<victauri_core::AppEvent> =
4798 page_json::parse_page_json::<Vec<serde_json::Value>>(&raw)
4799 .unwrap_or_default()
4800 .iter()
4801 .filter_map(|ev| crate::mcp::server::parse_bridge_event_from(ev, label))
4802 .filter(|ev| !ev.is_internal() && ev.timestamp() >= since)
4803 .collect();
4804 events.sort_by_key(victauri_core::AppEvent::timestamp);
4805 events
4806 }
4807
4808 fn resolve_visible_capture_target(&self, label: Option<&str>) -> Result<String, String> {
4823 let states = self
4824 .bridge
4825 .try_get_window_states(None)
4826 .map_err(|e| ui_busy(&e))?;
4827 if let Some(label) = label {
4828 if states.iter().any(|s| s.label == label && !s.visible) {
4832 return Err(format!(
4833 "window '{label}' is not visible — a native screenshot captures the \
4834 on-screen surface, and a hidden window has none (the OS capture would \
4835 return stale or another window's pixels). Show it first \
4836 (window action=manage manage_action=show label={label}), then capture."
4837 ));
4838 }
4839 return Ok(label.to_string());
4840 }
4841 states
4844 .iter()
4845 .find(|s| s.label == "main" && s.visible)
4846 .or_else(|| states.iter().find(|s| s.visible))
4847 .map(|st| st.label.clone())
4848 .ok_or_else(|| {
4849 "no visible window to capture — every window is hidden (or the UI is \
4850 not responding). Show one first (window action=manage \
4851 manage_action=show label=<label>), then capture."
4852 .to_string()
4853 })
4854 }
4855
4856 fn contained_or_missing(
4863 base: &std::path::Path,
4864 target: &std::path::Path,
4865 ) -> Result<bool, String> {
4866 let Ok(canon_base) = std::fs::canonicalize(base) else {
4868 return Ok(false);
4869 };
4870 let exists = target.exists();
4871 let probe = if exists {
4872 Some(target)
4873 } else {
4874 target.ancestors().skip(1).find(|a| a.exists())
4875 };
4876 if let Some(probe) = probe {
4877 let canonical = std::fs::canonicalize(probe)
4878 .map_err(|e| format!("cannot resolve target path: {e}"))?;
4879 if !canonical.starts_with(&canon_base) {
4880 return Err("path traversal not allowed".to_string());
4881 }
4882 }
4883 Ok(exists)
4884 }
4885
4886 fn matches_glob(name: &str, pattern: &str) -> bool {
4887 if pattern == "*" {
4888 return true;
4889 }
4890 if let Some(suffix) = pattern.strip_prefix("*.") {
4891 return name.ends_with(&format!(".{suffix}"));
4892 }
4893 if let Some(prefix) = pattern.strip_suffix("*") {
4894 return name.starts_with(prefix);
4895 }
4896 name == pattern
4897 }
4898
4899 async fn window_introspectability(&self) -> CallToolResult {
4905 let labels = match self.bridge.try_list_window_labels() {
4906 Ok(labels) => labels,
4907 Err(e) => return tool_error(ui_busy(&e)),
4908 };
4909 let states = match self.bridge.try_get_window_states(None) {
4910 Ok(states) => states,
4911 Err(e) => return tool_error(ui_busy(&e)),
4912 };
4913 let mut report = Vec::with_capacity(labels.len());
4914 let mut blind = 0usize;
4915 for label in &labels {
4916 let visible = states.iter().find(|s| &s.label == label).map(|s| s.visible);
4917 let introspectable = self.probe_bridge(Some(label), None, None).await.is_ok();
4918 if !introspectable {
4919 blind += 1;
4920 }
4921 let note = if introspectable {
4922 "ok — Victauri JS bridge is responding".to_string()
4923 } else if visible == Some(true) {
4924 format!(
4925 "NOT introspectable although the window is visible — almost certainly missing \
4926 the Victauri capability. Add \"victauri:default\" to the capability file \
4927 (src-tauri/capabilities/*.json) whose \"windows\" list includes \"{label}\", \
4928 then rebuild. Capabilities are baked at compile time, so a rebuild is required."
4929 )
4930 } else {
4931 "NOT introspectable (window is hidden and/or has no bridge) — show the window to \
4932 confirm, and ensure its capability includes \"victauri:default\", then rebuild."
4933 .to_string()
4934 };
4935 report.push(serde_json::json!({
4936 "label": label,
4937 "visible": visible,
4938 "introspectable": introspectable,
4939 "note": note,
4940 }));
4941 }
4942 let hint = if blind > 0 {
4943 "Windows with introspectable:false have no working Victauri JS bridge — eval_js, \
4944 dom_snapshot, animation, find_elements, etc. cannot see them. The usual cause is a \
4945 missing \"victauri:default\" capability for that window: Tauri's per-window permission \
4946 ACL silently blocks the bridge's callback IPC. This capability is required per window, \
4947 not just for the main window. (Note: probing a blind window takes ~2s each.)"
4948 } else {
4949 "All windows are introspectable."
4950 };
4951 json_result(&serde_json::json!({
4952 "windows": report,
4953 "introspectable_count": labels.len().saturating_sub(blind),
4954 "blind_count": blind,
4955 "hint": hint,
4956 }))
4957 }
4958
4959 async fn focus_for_trusted_input(
4963 &self,
4964 ref_id: &str,
4965 webview_label: Option<&str>,
4966 ) -> Result<(), CallToolResult> {
4967 let raw = self
4968 .eval_with_return(&trusted_focus_probe_js(ref_id), webview_label)
4969 .await
4970 .map_err(|e| {
4971 tool_error(format!(
4972 "could not focus {ref_id} before sending OS input: {e} — no keys were sent"
4973 ))
4974 })?;
4975 let answer = serde_json::from_str::<serde_json::Value>(&raw).unwrap_or_default();
4976 if answer.get("focused") == Some(&serde_json::Value::Bool(true)) {
4977 return Ok(());
4978 }
4979 let why = if answer.get("found") == Some(&serde_json::Value::Bool(true)) {
4980 "focus did not land on it (not focusable, inert, or a focus handler moved focus \
4981 elsewhere)"
4982 } else {
4983 "ref not found"
4984 };
4985 Err(tool_error_with_hint(
4986 format!(
4987 "ref not found or not focusable: {ref_id}: {why} — no keys were sent (they \
4988 would have gone to whatever holds focus)"
4989 ),
4990 RecoveryHint::CheckInput,
4991 ))
4992 }
4993
4994 async fn ipc_capture_status(&self, webview_label: Option<&str>) -> serde_json::Value {
5000 match self
5001 .eval_with_return_timeout(
5002 IPC_CAPTURE_STATUS_JS,
5003 webview_label,
5004 std::time::Duration::from_secs(5),
5005 )
5006 .await
5007 {
5008 Ok(raw) => page_json::parse_page_json::<serde_json::Value>(&raw)
5009 .ok()
5010 .filter(serde_json::Value::is_object)
5011 .unwrap_or(serde_json::Value::Null),
5012 Err(_) => serde_json::Value::Null,
5013 }
5014 }
5015
5016 async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
5017 match self.eval_with_return(code, webview_label).await {
5018 Ok(result) => CallToolResult::success(vec![ContentBlock::text(result)]),
5019 Err(e) => tool_error(e),
5020 }
5021 }
5022
5023 async fn eval_page_action(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
5029 match self.eval_with_return(code, webview_label).await {
5030 Ok(result) => match page_action_error(&result) {
5031 Some(message) => tool_error(message),
5032 None => CallToolResult::success(vec![ContentBlock::text(result)]),
5033 },
5034 Err(e) => tool_error(e),
5035 }
5036 }
5037
5038 async fn eval_with_return(
5039 &self,
5040 code: &str,
5041 webview_label: Option<&str>,
5042 ) -> Result<String, String> {
5043 self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
5044 .await
5045 }
5046
5047 async fn reserve_pending(
5055 &self,
5056 id: &str,
5057 tx: tokio::sync::oneshot::Sender<String>,
5058 ) -> Result<crate::PendingSlot, String> {
5059 let mut pending = self.state.pending_evals.lock().await;
5060 if pending.len() >= MAX_PENDING_EVALS {
5061 return Err(format!(
5062 "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
5063 ));
5064 }
5065 Ok(crate::PendingSlot::insert(
5066 &self.state.pending_evals,
5067 &mut pending,
5068 id.to_string(),
5069 tx,
5070 ))
5071 }
5072
5073 async fn probe_bridge(
5083 &self,
5084 webview_label: Option<&str>,
5085 deadline: Option<tokio::time::Instant>,
5086 answer_by: Option<tokio::time::Instant>,
5087 ) -> Result<Option<String>, String> {
5088 if deadline.is_some_and(|d| tokio::time::Instant::now() >= d) {
5089 return Err(
5090 "the call's time ran out before the page could be probed (the app's UI thread is \
5091 busy)"
5092 .to_string(),
5093 );
5094 }
5095 let id = uuid::Uuid::new_v4().to_string();
5096 let (tx, rx) = tokio::sync::oneshot::channel();
5097 let slot = self.reserve_pending(&id, tx).await?;
5098 slot.bind_window(webview_label);
5099 let probe = crate::js_bridge::eval_probe_script(&id);
5100 let injected = {
5101 let _budget = deadline.map(|d| crate::bridge::CallBudget::until(d.into_std()));
5102 self.bridge.eval_webview(webview_label, &probe)
5103 };
5104 if let Err(e) = injected {
5105 return Err(format!("eval injection failed: {e}"));
5106 }
5107 let wait = answer_by.map_or(PROBE_ANSWER_WINDOW, |d| {
5108 d.saturating_duration_since(tokio::time::Instant::now())
5109 .min(PROBE_ANSWER_WINDOW)
5110 });
5111 if let Ok(Ok(raw)) = tokio::time::timeout(wait, rx).await {
5112 Ok(crate::js_bridge::probe_answer_nonce(&raw))
5113 } else {
5114 let label = webview_label.unwrap_or("default");
5115 Err(format!(
5116 "bridge not responding on window '{label}' — the window may be hidden, \
5117 missing the victauri capability, or the JS bridge is not loaded (e.g. the page \
5118 failed to load: a dev-server connection-refused or blank error page has no JS \
5119 bridge — check the window with the `screenshot` tool, which works regardless)"
5120 ))
5121 }
5122 }
5123
5124 async fn page_replaced(
5143 &self,
5144 label: Option<&str>,
5145 seen: &mut u64,
5146 armed: Option<&str>,
5147 deadline: tokio::time::Instant,
5148 ) -> Option<String> {
5149 let label = label?;
5150 let load = self.state.page_loads.latest(label)?;
5151 if load.seq <= *seen {
5152 return None;
5153 }
5154 if tokio::time::Instant::now() >= deadline {
5155 return None;
5156 }
5157 let previously_seen = *seen;
5158 *seen = load.seq;
5159 if armed.is_some() && load.nonce.as_deref() == armed {
5160 return None;
5161 }
5162 match self
5165 .probe_bridge(Some(label), Some(deadline), Some(deadline))
5166 .await
5167 {
5168 Ok(current) if current.as_deref() != armed => Some(format!(
5169 "window '{label}' loaded a new page (a reload or navigation) while the call was \
5170 in flight, so no result will arrive. The code may or may not have run before \
5171 the reload — check the app's state before re-running it."
5172 )),
5173 Ok(_) => None,
5174 Err(e) => {
5175 tracing::debug!(
5176 window = label,
5177 "ready signal not confirmed ({e}); the eval keeps waiting"
5178 );
5179 *seen = previously_seen;
5181 None
5182 }
5183 }
5184 }
5185
5186 async fn final_recording_flush(&self) -> Vec<String> {
5190 let Ok(labels) = self.bridge.try_list_window_labels() else {
5191 return vec!["(window list unavailable: UI thread busy)".to_string()];
5192 };
5193 let mut pending = labels.clone();
5194 let flushed = tokio::time::timeout(FINAL_FLUSH_BUDGET, async {
5195 for label in &labels {
5196 if crate::mcp::server::drain_window_into_recording(&self.state, &self.bridge, label)
5197 .await
5198 .is_some()
5199 {
5200 pending.retain(|l| l != label);
5201 }
5202 }
5203 })
5204 .await;
5205 if flushed.is_err() {
5206 tracing::debug!("recording stop: final flush ran out of time");
5207 }
5208 pending
5209 }
5210
5211 async fn eval_with_return_timeout(
5212 &self,
5213 code: &str,
5214 webview_label: Option<&str>,
5215 timeout: std::time::Duration,
5216 ) -> Result<String, String> {
5217 self.eval_outcome(code, webview_label, timeout)
5218 .await
5219 .map_err(|f| f.message)
5220 }
5221
5222 #[allow(clippy::too_many_lines)]
5224 async fn eval_outcome(
5225 &self,
5226 code: &str,
5227 webview_label: Option<&str>,
5228 timeout: std::time::Duration,
5229 ) -> Result<String, EvalFailure> {
5230 use EvalFailureKind::{Aborted, NotSent, Page};
5231
5232 let started = tokio::time::Instant::now();
5248 let deadline = started + timeout;
5249 let send_deadline = deadline.max(started + MIN_SEND_WINDOW);
5250
5251 if !self
5263 .state
5264 .bridge_ready
5265 .load(std::sync::atomic::Ordering::Acquire)
5266 {
5267 let notified = self.state.bridge_notify.notified();
5268 if !self
5269 .state
5270 .bridge_ready
5271 .load(std::sync::atomic::Ordering::Acquire)
5272 {
5273 let ready_wait = std::time::Duration::from_secs(5).min(
5277 send_deadline
5278 .saturating_duration_since(tokio::time::Instant::now())
5279 .saturating_sub(PROBE_ANSWER_WINDOW),
5280 );
5281 let _ = tokio::time::timeout(ready_wait, notified).await;
5282 }
5283 }
5284
5285 let mut shutdown = self.state.shutdown_tx.subscribe();
5289 if *shutdown.borrow() {
5290 return Err(EvalFailure::new(
5291 NotSent,
5292 "the app is shutting down, so the call was not sent",
5293 ));
5294 }
5295
5296 let label_key =
5299 webview_label.map_or_else(|| "\u{1}__default__".to_string(), str::to_string);
5300
5301 let mut loads_seen = self.state.page_loads.current_seq();
5304
5305 let prev_timed_out = self.timed_out_labels.lock().await.remove(&label_key);
5318 let armed_nonce = match self
5319 .probe_bridge(webview_label, Some(send_deadline), None)
5320 .await
5321 {
5322 Ok(nonce) => nonce,
5323 Err(e) if e.contains("(it will not run)") => {
5326 return Err(EvalFailure::new(
5327 NotSent,
5328 format!(
5329 "eval ran out of time before the code was sent ({} elapsed, timeout {}) \
5330 — the app's UI thread is busy (the liveness probe could not be \
5331 delivered: {e}). The code did NOT run; retry when the app is \
5332 responsive, or pass a longer timeout.",
5333 format_timeout(started.elapsed()),
5334 format_timeout(timeout)
5335 ),
5336 ));
5337 }
5338 Err(e) => {
5339 return Err(EvalFailure::new(
5340 NotSent,
5341 if prev_timed_out {
5342 format!(
5343 "{e} (a previous eval on this window also timed out — the webview \
5344 likely reloaded or the app stopped responding)"
5345 )
5346 } else {
5347 e
5348 },
5349 ));
5350 }
5351 };
5352
5353 let id = uuid::Uuid::new_v4().to_string();
5354 let (tx, rx) = tokio::sync::oneshot::channel();
5355 let slot = self
5356 .reserve_pending(&id, tx)
5357 .await
5358 .map_err(|e| EvalFailure::new(NotSent, e))?;
5359 slot.bind_window(webview_label);
5361
5362 let body = strip_leading_js_comments(code.trim());
5371 let code = if should_prepend_return(body) {
5372 format!("return {body}")
5373 } else {
5374 code.trim().to_string()
5375 };
5376
5377 if tokio::time::Instant::now() >= send_deadline {
5396 return Err(EvalFailure::new(
5397 NotSent,
5398 format!(
5399 "eval ran out of time before the code was sent ({} elapsed, timeout {}) — \
5400 the app's UI thread is busy (the page answered the liveness probe late). \
5401 The code did NOT run; retry when the app is responsive, or pass a longer \
5402 timeout.",
5403 format_timeout(started.elapsed()),
5404 format_timeout(timeout)
5405 ),
5406 ));
5407 }
5408 let inject = crate::js_bridge::eval_wrapper_script(&id, &code);
5409 let target = {
5410 let _budget = crate::bridge::CallBudget::until(send_deadline.into_std());
5411 self.bridge.eval_webview_resolved(webview_label, &inject)
5412 }
5413 .map_err(|e| {
5414 if crate::bridge::closure_may_have_run(&e) {
5417 EvalFailure::new(
5418 Aborted,
5419 format!(
5420 "eval delivery did not finish: {e}. The code may or may not have run — \
5421 check the app's state before re-running it."
5422 ),
5423 )
5424 } else if e.contains("(it will not run)") {
5425 EvalFailure::new(
5428 NotSent,
5429 format!(
5430 "eval ran out of time before the code was sent ({} elapsed, timeout {}) \
5431 — the app's UI thread is busy ({e}). The code did NOT run; retry when \
5432 the app is responsive, or pass a longer timeout.",
5433 format_timeout(started.elapsed()),
5434 format_timeout(timeout)
5435 ),
5436 )
5437 } else {
5438 EvalFailure::new(NotSent, format!("eval injection failed: {e}"))
5439 }
5440 })?;
5441 let result_floor = timeout.min(MIN_RESULT_WINDOW);
5444 let deadline = deadline.max(tokio::time::Instant::now() + result_floor);
5445 let deliver_to = if target.is_empty() {
5446 webview_label
5447 } else {
5448 slot.bind_window(Some(target.as_str()));
5449 Some(target.as_str())
5450 };
5451 let check = crate::js_bridge::eval_check_script(&id, armed_nonce.as_deref());
5452 let check_sent = {
5458 let _budget = crate::bridge::CallBudget::until(deadline.into_std());
5459 self.bridge.eval_webview(deliver_to, &check)
5460 };
5461 let deadline = deadline.max(tokio::time::Instant::now() + result_floor);
5462 let parse_check_armed = match check_sent {
5463 Ok(()) => armed_nonce.is_some(),
5464 Err(e) => {
5465 tracing::debug!("eval parse check not delivered: {e}");
5467 false
5468 }
5469 };
5470
5471 let watched: Option<String> = (!target.is_empty()).then_some(target);
5483 let mut liveness = tokio::time::interval_at(
5484 tokio::time::Instant::now() + EVAL_WINDOW_WATCH_INTERVAL,
5485 EVAL_WINDOW_WATCH_INTERVAL,
5486 );
5487 let mut check: Option<tokio::task::JoinHandle<Result<Vec<String>, String>>> = None;
5488 let armed = armed_nonce.as_deref();
5489 let mut rx = rx;
5490 let outcome = if let Some(msg) = self
5492 .page_replaced(watched.as_deref(), &mut loads_seen, armed, deadline)
5493 .await
5494 {
5495 Err(Some(msg))
5496 } else {
5497 loop {
5498 tokio::select! {
5499 biased;
5502 r = &mut rx => break Ok(r),
5503 () = self.state.page_loads.changed() => {
5504 if let Some(msg) =
5505 self.page_replaced(watched.as_deref(), &mut loads_seen, armed, deadline).await
5506 {
5507 break Err(Some(msg));
5508 }
5509 }
5510 () = tokio::time::sleep_until(deadline) => break Err(None),
5511 changed = shutdown.changed() => {
5512 if changed.is_err() || *shutdown.borrow() {
5513 break Err(Some(
5514 "the app began shutting down while the call was in flight, so \
5515 no result will arrive. If the code/command quits or restarts the \
5516 app, this is the expected outcome and it most likely ran — do \
5517 not re-run it blindly."
5518 .to_string(),
5519 ));
5520 }
5521 }
5522 _ = liveness.tick(), if watched.is_some() && check.is_none() => {
5523 if let Some(msg) =
5525 self.page_replaced(watched.as_deref(), &mut loads_seen, armed, deadline).await
5526 {
5527 break Err(Some(msg));
5528 }
5529 let bridge = Arc::clone(&self.bridge);
5530 check = Some(tokio::spawn(async move { bridge.try_list_window_labels() }));
5531 }
5532 listed = async { check.as_mut().expect("guarded by precondition").await },
5533 if check.is_some() =>
5534 {
5535 check = None;
5536 if let (Ok(Ok(labels)), Some(label)) = (listed, watched.as_deref())
5537 && !labels.iter().any(|l| l == label)
5538 {
5539 break Err(Some(format!(
5540 "window '{label}' was closed while the call was in flight, so no \
5541 result will arrive. If the code/command closes this window (or \
5542 quits the app), this is the expected outcome and it most likely \
5543 ran — do not re-run it blindly."
5544 )));
5545 }
5546 }
5547 }
5548 }
5549 };
5550 if let Some(pending_check) = check {
5551 pending_check.abort();
5552 }
5553 let early = match outcome {
5554 Ok(r) => Ok(r),
5555 Err(Some(msg)) => match rx.try_recv() {
5557 Ok(raw) => Ok(Ok(raw)),
5558 Err(_) => return Err(EvalFailure::new(Aborted, msg)),
5559 },
5560 Err(None) => rx.try_recv().map(Ok).map_err(|_| ()),
5563 };
5564 match early {
5565 Ok(Ok(raw)) => {
5566 self.check_bridge_version_once();
5567 if raw.len() > MAX_EVAL_RESULT_LEN {
5568 return Err(EvalFailure::new(
5569 Page,
5570 format!(
5571 "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
5572 raw.len()
5573 ),
5574 ));
5575 }
5576 unwrap_eval_envelope(raw)
5577 }
5578 Ok(Err(_)) => Err(EvalFailure::new(Aborted, "eval callback channel closed")),
5579 Err(()) => {
5580 self.timed_out_labels.lock().await.insert(label_key.clone());
5584 let (began, parse_note) = if parse_check_armed {
5585 (
5586 "the code began executing but never resolved",
5587 "(A syntax/parse error is reported immediately, so this is NOT a parse \
5588 error.) Common causes",
5589 )
5590 } else {
5591 (
5592 "no result arrived",
5593 "(The fast syntax-error check could not run for this call, so a parse \
5594 error could not be ruled out — check the code's syntax.) Other causes",
5595 )
5596 };
5597 Err(EvalFailure::new(
5598 Aborted,
5599 format!(
5600 "eval timed out after {} — {began}. {parse_note}: an unresolved \
5601 promise, an infinite loop, an `await` on something that never settles, \
5602 or the webview reloaded / the app stopped responding mid-eval. If the \
5603 app may have navigated or crashed, retry (the next call fails fast if \
5604 the bridge is gone).",
5605 format_timeout(timeout)
5606 ),
5607 ))
5608 }
5609 }
5610 }
5611
5612 #[cfg(feature = "sqlite")]
5613 async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
5614 let roots = self.db_roots();
5615
5616 let path = if let Some(p) = db_path {
5617 Self::resolve_existing_db_path(&roots, p)?
5618 } else {
5619 let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
5623 roots.clone()
5624 } else {
5625 self.state.db_search_paths.clone()
5626 };
5627 tokio::task::spawn_blocking(move || crate::database::select_app_database(&select_dirs))
5629 .await
5630 .map_err(|e| format!("database discovery failed: {e}"))??
5631 };
5632 let path_str = path
5633 .to_str()
5634 .ok_or_else(|| "invalid path encoding".to_string())?
5635 .to_string();
5636
5637 bounded::run_blocking_bounded(
5638 Some(&bounded::DB_SLOTS),
5639 "db health check",
5640 crate::database::DB_HEALTH_META_BUDGET
5641 + DB_HEALTH_COUNT_BUDGET
5642 + DB_HEALTH_CHECK_BUDGET
5643 + bounded::BLOCKING_DEADLINE_SLACK,
5644 move || {
5645 crate::database::db_health_report(
5646 &path_str,
5647 DB_HEALTH_COUNT_BUDGET,
5648 DB_HEALTH_CHECK_BUDGET,
5649 )
5650 },
5651 )
5652 .await
5653 }
5654
5655 fn check_bridge_version_once(&self) {
5656 if self.bridge_checked.swap(true, Ordering::Relaxed) {
5657 return;
5658 }
5659 let handler = self.clone();
5660 tokio::spawn(async move {
5661 match handler
5662 .eval_with_return_timeout(
5663 "window.__VICTAURI__?.version",
5664 None,
5665 std::time::Duration::from_secs(5),
5666 )
5667 .await
5668 {
5669 Ok(v) => {
5670 let v = v.trim_matches('"');
5671 if v == BRIDGE_VERSION {
5672 tracing::debug!("Bridge version verified: {v}");
5673 } else {
5674 tracing::warn!(
5675 "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
5676 );
5677 }
5678 }
5679 Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
5680 }
5681 });
5682 }
5683}
5684
5685const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
5686It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
5687(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
5688(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
5689\n\nBACKEND tools (direct Rust access, no webview needed): \
5690'app_info' (app config, directory paths, discovered databases, process info), \
5691'list_app_dir' (browse app data/config/log directories), \
5692'read_app_file' (read files from app directories), \
5693'query_db' (read-only SQLite queries with auto-discovery). \
5694\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
5695'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
5696capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
5697Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
5698capability/security auditing, database diagnostics, plugin state, child process enumeration, \
5699task tracking, and automatic Tauri event bus monitoring. \
5700'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
5701drops, and response corruption into Tauri commands at the Rust layer. \
5702'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
5703activity across IPC + DOM + console + network + window events into a coherent narrative. \
5704\n\nWEBVIEW tools: \
5705'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
5706'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
5707'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
5708\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
5709\n\nCOMPOUND tools with an 'action' parameter: \
5710'window' (get_state, list, manage, resize, move_to, set_title), \
5711'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
5712set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
5713get_events, events_between, get_replay, export, import, replay), \
5714'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
5715\n\nOTHER: verify_state, wait_for (incl. 'expression'/'event' conditions to await \
5716async backend work to true completion), assert_semantic, resolve_command, \
5717app_state (app-defined backend state probes), \
5718get_memory_stats, get_plugin_info, get_diagnostics.";
5719
5720impl ServerHandler for VictauriMcpHandler {
5721 fn get_info(&self) -> InitializeResult {
5722 InitializeResult::new(
5728 ServerCapabilities::builder()
5729 .enable_tools()
5730 .enable_resources()
5731 .build(),
5732 )
5733 .with_instructions(SERVER_INSTRUCTIONS)
5734 }
5735
5736 async fn list_tools(
5737 &self,
5738 _request: Option<PaginatedRequestParams>,
5739 _context: RequestContext<RoleServer>,
5740 ) -> Result<ListToolsResult, ErrorData> {
5741 let all_tools = Self::tool_router().list_all();
5742 let filtered: Vec<Tool> = all_tools
5743 .into_iter()
5744 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
5745 .collect();
5746 let mut result = ListToolsResult::with_all_items(filtered);
5751 result.ttl_ms = Some(LIST_RESULT_TTL_MS);
5752 result.cache_scope = Some(CacheScope::Private);
5753 Ok(result)
5754 }
5755
5756 async fn call_tool(
5757 &self,
5758 request: CallToolRequestParams,
5759 context: RequestContext<RoleServer>,
5760 ) -> Result<CallToolResponse, ErrorData> {
5761 let tool_name: String = request.name.as_ref().to_owned();
5762 let args_value = serde_json::Value::Object(request.arguments.clone().unwrap_or_default());
5765 if self.state.privacy.disabled_tools.contains(&tool_name) {
5767 return Ok(tool_disabled(&tool_name).into());
5768 }
5769 let capability = authz::resolve_capability(&tool_name, &args_value)
5770 .map_err(|msg| ErrorData::invalid_params(msg, None))?;
5771 if !self.state.privacy.is_call_allowed(&tool_name, &capability) {
5772 tracing::debug!(tool = %tool_name, capability = %capability, "tool call blocked by privacy config");
5773 return Ok(tool_disabled(&capability).into());
5774 }
5775 self.state
5776 .tool_invocations
5777 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5778 let start = std::time::Instant::now();
5779 tracing::debug!(tool = %tool_name, "tool invocation started");
5780 let ctx = ToolCallContext::new(self, request, context);
5781 let response = bounded::CatchUnwind::new(Self::tool_router().call(ctx))
5784 .await
5785 .unwrap_or_else(|panic| {
5786 tracing::error!(tool = %tool_name, "tool handler panicked: {panic}");
5787 Ok(tool_panicked(&tool_name, &panic).into())
5788 });
5789 let elapsed = start.elapsed();
5790 tracing::debug!(
5791 tool = %tool_name,
5792 elapsed_ms = elapsed.as_millis() as u64,
5793 is_error = response.as_ref().map_or(true, |r| match r {
5794 CallToolResponse::Complete(r) => r.is_error.unwrap_or(false),
5795 _ => false,
5796 }),
5797 "tool invocation completed"
5798 );
5799
5800 if self.state.privacy.redaction_enabled {
5805 response.map(|resp| match resp {
5806 CallToolResponse::Complete(mut r) => {
5807 for item in &mut r.content {
5808 if let ContentBlock::Text(tc) = item {
5809 tc.text = self.state.privacy.redact_output(&tc.text);
5810 }
5811 }
5812 CallToolResponse::Complete(r)
5813 }
5814 other => other,
5815 })
5816 } else {
5817 response
5818 }
5819 }
5820
5821 fn get_tool(&self, name: &str) -> Option<Tool> {
5822 if !self.state.privacy.is_tool_enabled(name) {
5823 return None;
5824 }
5825 Self::tool_router().get(name).cloned()
5826 }
5827
5828 async fn list_resources(
5829 &self,
5830 _request: Option<PaginatedRequestParams>,
5831 _context: RequestContext<RoleServer>,
5832 ) -> Result<ListResourcesResult, ErrorData> {
5833 let mut result = ListResourcesResult::with_all_items(vec![
5836 Resource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
5837 .with_description(
5838 "Live IPC call log — all commands invoked between frontend and backend",
5839 )
5840 .with_mime_type("application/json"),
5841 Resource::new(RESOURCE_URI_WINDOWS, "windows")
5842 .with_description(
5843 "Current state of all Tauri windows — position, size, visibility, focus",
5844 )
5845 .with_mime_type("application/json"),
5846 Resource::new(RESOURCE_URI_STATE, "state")
5847 .with_description(
5848 "Victauri plugin state — event count, registered commands, memory stats",
5849 )
5850 .with_mime_type("application/json"),
5851 ]);
5852 result.ttl_ms = Some(LIST_RESULT_TTL_MS);
5853 result.cache_scope = Some(CacheScope::Private);
5854 Ok(result)
5855 }
5856
5857 async fn read_resource(
5858 &self,
5859 request: ReadResourceRequestParams,
5860 _context: RequestContext<RoleServer>,
5861 ) -> Result<ReadResourceResponse, ErrorData> {
5862 let uri = &request.uri;
5863 if !resource_allowed(&self.state.privacy, uri.as_str()) {
5867 return Err(ErrorData::invalid_request(
5868 format!("resource {uri} is not permitted by the current privacy configuration"),
5869 None,
5870 ));
5871 }
5872 let json = match uri.as_str() {
5873 RESOURCE_URI_IPC_LOG => {
5874 let code = trimmed_log_js(
5881 &format!("window.__VICTAURI__?.getIpcLog({DEFAULT_LOG_LIMIT})"),
5882 DEFAULT_LOG_LIMIT,
5883 );
5884 if let Ok(json) = self.eval_with_return(&code, None).await {
5885 json
5886 } else {
5887 let calls = self.state.event_log.ipc_calls();
5888 serde_json::to_string_pretty(&calls)
5889 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
5890 }
5891 }
5892 RESOURCE_URI_WINDOWS => {
5893 let states = self
5894 .bridge
5895 .try_get_window_states(None)
5896 .map_err(|e| ErrorData::internal_error(ui_busy(&e), None))?;
5897 serde_json::to_string_pretty(&states)
5898 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
5899 }
5900 RESOURCE_URI_STATE => {
5901 let state_json = serde_json::json!({
5902 "events_captured": self.state.event_log.len(),
5903 "commands_registered": self.state.registry.count(),
5904 "memory": crate::memory::current_stats(),
5905 "port": self.state.port.load(Ordering::Relaxed),
5906 });
5907 serde_json::to_string_pretty(&state_json)
5908 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
5909 }
5910 _ => {
5911 return Err(ErrorData::resource_not_found(
5912 format!("unknown resource: {uri}"),
5913 None,
5914 ));
5915 }
5916 };
5917
5918 let json = if self.state.privacy.redaction_enabled {
5919 self.state.privacy.redact_output(&json)
5920 } else {
5921 json
5922 };
5923
5924 Ok(ReadResourceResult::new(vec![ResourceContents::text(json, uri)]).into())
5925 }
5926
5927 #[allow(deprecated)]
5933 async fn subscribe(
5934 &self,
5935 request: SubscribeRequestParams,
5936 _context: RequestContext<RoleServer>,
5937 ) -> Result<(), ErrorData> {
5938 let uri = &request.uri;
5939 if !resource_allowed(&self.state.privacy, uri.as_str()) {
5942 return Err(ErrorData::invalid_request(
5943 format!("resource {uri} is not permitted by the current privacy configuration"),
5944 None,
5945 ));
5946 }
5947 match uri.as_str() {
5948 RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
5949 self.subscriptions.lock().await.insert(uri.clone());
5950 tracing::info!("Client subscribed to resource: {uri}");
5951 Ok(())
5952 }
5953 _ => Err(ErrorData::resource_not_found(
5954 format!("unknown resource: {uri}"),
5955 None,
5956 )),
5957 }
5958 }
5959
5960 #[allow(deprecated)]
5961 async fn unsubscribe(
5962 &self,
5963 request: UnsubscribeRequestParams,
5964 _context: RequestContext<RoleServer>,
5965 ) -> Result<(), ErrorData> {
5966 self.subscriptions.lock().await.remove(&request.uri);
5967 tracing::info!("Client unsubscribed from resource: {}", request.uri);
5968 Ok(())
5969 }
5970}
5971
5972fn trim_field_js() -> String {
5977 let mb = MAX_LOG_FIELD_BYTES;
5978 format!(
5979 r"var MB = {mb};
5980 function trimField(v) {{
5981 if (typeof v === 'string') {{
5982 if (v.length <= MB) return v;
5983 var end = MB, c = v.charCodeAt(end - 1);
5984 if (c >= 0xD800 && c <= 0xDBFF) end--;
5985 return v.slice(0, end) + '…[+' + (v.length - end) + ' bytes truncated]';
5986 }}
5987 if (v && typeof v === 'object') {{
5988 var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }}
5989 if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }}
5990 }}
5991 return v;
5992 }}"
5993 )
5994}
5995
5996#[doc(hidden)]
6000#[must_use]
6001pub fn ipc_integrity_js(threshold_ms: i64) -> String {
6002 format!(
6003 r"return (function() {{
6004 var V = window.__VICTAURI__;
6005 var log = V?.getIpcLog(0, {{ bodies: false }}) || [];
6006 var now = Date.now();
6007 var threshold = {threshold_ms};
6008 var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
6009 var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
6010 var errored = log.filter(function(c) {{ return c.status === 'error'; }});
6011 var netCount = (V?.getNetworkLog(null, 0, {{ bodies: false }}) || []).length;
6012 var capture = (V && V.getIpcCaptureStatus) ? V.getIpcCaptureStatus() : null;
6013 var warning = null;
6014 if (capture && (capture.capture === 'none' || capture.capture === 'requests_only')) {{
6015 warning = capture.note;
6016 }} else if (log.length === 0 && netCount > 5) {{
6017 warning = 'Zero IPC calls captured but ' + netCount + ' network requests observed. IPC capture may not be working — check ipc_capture / get_diagnostics.';
6018 }}
6019 function withBodies(list) {{
6020 list = list.slice(0, 20);
6021 if (!list.length) return list;
6022 var got = V.getIpcLog(0, {{ ids: list.map(function(c) {{ return c.id; }}) }}) || [];
6023 var byId = {{}};
6024 for (var i = 0; i < got.length; i++) byId[got[i].id] = got[i];
6025 return list.map(function(c) {{ return byId[c.id] || c; }});
6026 }}
6027 // INTEGRITY = round-trip soundness: no stuck/stale (never-returned) calls.
6028 // A command that completed with an Err is a HEALTHY round-trip (it returned)
6029 // — every real app exercises error paths, so counting those as 'unhealthy'
6030 // would cry wolf. The error_count/errored_calls surface them for visibility,
6031 // but only stale calls flip `healthy`.
6032 return {{
6033 healthy: stale.length === 0,
6034 total_calls: log.length,
6035 pending_count: pending.length,
6036 stale_count: stale.length,
6037 error_count: errored.length,
6038 stale_calls: withBodies(stale),
6039 errored_calls: withBodies(errored),
6040 warning: warning,
6041 ipc_capture: capture
6042 }};
6043 }})()"
6044 )
6045}
6046
6047#[doc(hidden)]
6050#[must_use]
6051pub fn slow_ipc_js(threshold_ms: u64, limit: usize) -> String {
6052 let trim_field = trim_field_js();
6053 format!(
6054 r"return (function() {{
6055 {trim_field}
6056 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; }}
6057 var V = window.__VICTAURI__;
6058 var log = V?.getIpcLog(0, {{ bodies: false }}) || [];
6059 var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold_ms}; }});
6060 slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
6061 var top = slow.slice(0, {limit});
6062 if (top.length) {{
6063 var got = V.getIpcLog(0, {{ ids: top.map(function(c) {{ return c.id; }}) }}) || [];
6064 var byId = {{}};
6065 for (var i = 0; i < got.length; i++) byId[got[i].id] = got[i];
6066 top = top.map(function(c) {{ return byId[c.id] || c; }});
6067 }}
6068 return {{ threshold_ms: {threshold_ms}, count: top.length, calls: top.map(trimEntry) }};
6069 }})()",
6070 )
6071}
6072
6073const IPC_CAPTURE_STATUS_JS: &str = "return (window.__VICTAURI__ && typeof window.__VICTAURI__.getIpcCaptureStatus === 'function') ? window.__VICTAURI__.getIpcCaptureStatus() : null";
6075
6076#[doc(hidden)]
6083#[must_use]
6084pub fn trimmed_log_js(source_expr: &str, limit: usize) -> String {
6085 let trim_field = trim_field_js();
6086 format!(
6087 r"return (function() {{
6088 {trim_field}
6089 function trimEntry(e) {{
6090 if (e == null || typeof e !== 'object') return e;
6091 var out = Array.isArray(e) ? [] : {{}};
6092 for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) out[k] = trimField(e[k]); }}
6093 return out;
6094 }}
6095 var arr = {source_expr} || [];
6096 if (arr.length > {limit}) arr = arr.slice(arr.length - {limit}); // not slice(-0): all
6097 return arr.map(trimEntry);
6098 }})()"
6099 )
6100}
6101
6102fn now_ms() -> f64 {
6106 #[allow(clippy::cast_precision_loss)]
6107 let ms = chrono::Utc::now().timestamp_millis() as f64;
6108 ms
6109}
6110
6111fn unwrap_eval_envelope(raw: String) -> Result<String, EvalFailure> {
6122 if let Ok(envelope) = page_json::parse_page_json::<serde_json::Value>(&raw) {
6125 if let Some(why) = envelope.get("__victauri_not_run") {
6126 return Err(EvalFailure::new(
6127 EvalFailureKind::NotSent,
6128 format!(
6129 "{PARSE_ERROR_PREFIX} {}",
6130 why.as_str().unwrap_or("the code did not begin executing")
6131 ),
6132 ));
6133 }
6134 if let Some(err) = envelope.get("__victauri_err") {
6135 return Err(EvalFailure::new(
6136 EvalFailureKind::Page,
6137 format!(
6138 "JavaScript error: {}",
6139 err.as_str().unwrap_or("unknown error")
6140 ),
6141 ));
6142 }
6143 if let Some(why) = envelope.get("__victauri_unserializable") {
6144 return Err(EvalFailure::new(
6145 EvalFailureKind::Page,
6146 format!(
6147 "the code ran, but its result could not be serialized to JSON ({}). Return a JSON-serializable value instead — e.g. String() a BigInt, or pick the fields you need from a circular object.",
6148 why.as_str().unwrap_or("unknown reason")
6149 ),
6150 ));
6151 }
6152 if envelope.get("__victauri_ok").is_some() {
6153 let js_type = envelope
6154 .get("__victauri_type")
6155 .and_then(|t| t.as_str())
6156 .unwrap_or("value");
6157 return match js_type {
6158 "undefined" => Ok("undefined".to_string()),
6159 "null" => Ok("null".to_string()),
6160 _ => Ok(serde_json::to_string(&envelope["__victauri_ok"])
6161 .unwrap_or_else(|_| "null".to_string())),
6162 };
6163 }
6164 }
6165 if let Some(after) = raw.strip_prefix(r#"{"__victauri_ok":"#)
6171 && let Some(idx) = after.rfind(r#","__victauri_type":"#)
6172 {
6173 return Ok(after[..idx].to_string());
6174 }
6175 if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) {
6176 let msg = after.trim_end_matches('}').trim_matches('"');
6177 return Err(EvalFailure::new(
6178 EvalFailureKind::Page,
6179 format!("JavaScript error: {msg}"),
6180 ));
6181 }
6182 Ok(raw)
6183}
6184
6185fn expression_eval_code(expr: &str) -> String {
6197 if should_prepend_return(expr) {
6198 return expr.to_string();
6199 }
6200 let body = expr.trim_end_matches(|c: char| c == ';' || is_js_space(c));
6201 format!("return (\n{body}\n);")
6202}
6203
6204fn parse_expression_value(raw: &str) -> Result<serde_json::Value, serde_json::Error> {
6209 if raw == "undefined" {
6210 return Ok(serde_json::Value::Null);
6211 }
6212 serde_json::from_str(raw)
6213}
6214
6215fn page_action_error(result: &str) -> Option<String> {
6221 let value: serde_json::Value = page_json::parse_page_json(result).ok()?;
6222 let obj = value.as_object()?;
6223 let ok = obj.get("ok").and_then(serde_json::Value::as_bool);
6224 let error = obj.get("error").and_then(serde_json::Value::as_str);
6225 if ok == Some(true) || (ok.is_none() && error.is_none()) {
6226 return None;
6227 }
6228 let mut message = error.map_or_else(
6229 || format!("the page reported failure: {value}"),
6230 str::to_string,
6231 );
6232 if let Some(hint) = obj.get("hint").and_then(serde_json::Value::as_str)
6233 && !hint.is_empty()
6234 && hint.len() <= 32
6235 && hint.bytes().all(|b| b.is_ascii_uppercase() || b == b'_')
6236 {
6237 message.push_str(&format!("\n\n[hint: {hint}]"));
6238 }
6239 Some(message)
6240}
6241
6242const STMT_KEYWORDS: &[&str] = &[
6245 "return", "if", "for", "while", "switch", "try", "const", "let", "var", "function", "class",
6246 "throw", "do", "debugger", "with",
6247];
6248
6249fn starts_with_word(code: &str, word: &str) -> bool {
6251 code.starts_with(word)
6252 && code[word.len()..]
6253 .chars()
6254 .next()
6255 .is_none_or(|c| !is_js_ident_char(c))
6256}
6257
6258fn starts_with_statement(code: &str) -> bool {
6262 if code.starts_with('{') || STMT_KEYWORDS.iter().any(|k| starts_with_word(code, k)) {
6263 return true;
6264 }
6265 if starts_with_word(code, "async") && starts_with_word(code[5..].trim_start(), "function") {
6266 return true;
6267 }
6268 let ident_len: usize = code
6270 .chars()
6271 .take_while(|&c| is_js_ident_char(c))
6272 .map(char::len_utf8)
6273 .sum();
6274 ident_len > 0
6275 && !code.as_bytes()[0].is_ascii_digit()
6276 && code[ident_len..].trim_start().starts_with(':')
6277}
6278
6279pub(crate) fn read_regular_file(
6284 path: &std::path::Path,
6285 max_bytes: usize,
6286) -> Result<(Vec<u8>, usize, Option<u64>), String> {
6287 use std::io::Read;
6288 let f = std::fs::File::open(path).map_err(|e| e.to_string())?;
6289 let metadata = f.metadata().map_err(|e| e.to_string())?;
6290 if !metadata.is_file() {
6291 return Err("not a regular file".to_string());
6292 }
6293 let modified = metadata.modified().ok().map(|t| {
6294 t.duration_since(std::time::SystemTime::UNIX_EPOCH)
6295 .unwrap_or_default()
6296 .as_secs()
6297 });
6298 let mut buf = Vec::new();
6299 f.take(max_bytes as u64 + 1)
6300 .read_to_end(&mut buf)
6301 .map_err(|e| e.to_string())?;
6302 let size = usize::try_from(metadata.len()).unwrap_or(usize::MAX);
6303 Ok((buf, size, modified))
6304}
6305
6306#[cfg(feature = "sqlite")]
6308fn lexically_normalize(path: &std::path::Path) -> std::path::PathBuf {
6309 let mut out = std::path::PathBuf::new();
6310 for component in path.components() {
6311 match component {
6312 std::path::Component::CurDir => {}
6313 std::path::Component::ParentDir => {
6314 out.pop();
6315 }
6316 other => out.push(other),
6317 }
6318 }
6319 out
6320}
6321
6322const ASI_CONTINUES_AFTER: &[u8] = b"+-*/%&|^!=<>?:,.([{~";
6324const ASI_CONTINUES_BEFORE: &[u8] = b".?)]}+-*/%&|^=<>,:([`";
6327
6328fn line_terminator_len(bytes: &[u8], i: usize) -> usize {
6331 match bytes.get(i) {
6332 Some(b'\n') => 1,
6333 Some(b'\r') => 1 + usize::from(bytes.get(i + 1) == Some(&b'\n')),
6334 Some(0xE2)
6335 if bytes.get(i + 1) == Some(&0x80) && matches!(bytes.get(i + 2), Some(0xA8 | 0xA9)) =>
6336 {
6337 3
6338 }
6339 _ => 0,
6340 }
6341}
6342
6343fn find_line_terminator(bytes: &[u8], from: usize) -> Option<usize> {
6345 (from..bytes.len()).find(|&i| line_terminator_len(bytes, i) > 0)
6346}
6347
6348fn after_line(code: &str) -> &str {
6350 let bytes = code.as_bytes();
6351 find_line_terminator(bytes, 0).map_or("", |n| &code[n + line_terminator_len(bytes, n)..])
6352}
6353
6354fn strip_leading_js_comments(mut code: &str) -> &str {
6358 loop {
6359 code = code.trim_start();
6360 if let Some(rest) = code
6361 .strip_prefix("//")
6362 .or_else(|| code.strip_prefix("<!--"))
6363 .or_else(|| code.strip_prefix("-->"))
6364 {
6365 code = after_line(rest);
6366 } else if let Some(rest) = code.strip_prefix("/*") {
6367 code = rest.find("*/").map_or("", |n| &rest[n + 2..]);
6368 } else {
6369 return code;
6370 }
6371 }
6372}
6373
6374#[derive(PartialEq, Clone, Copy)]
6378enum Bracket {
6379 Plain,
6380 ControlHead,
6381 ForHead,
6382}
6383
6384#[derive(PartialEq, Clone, Copy)]
6386enum ScanState {
6387 Code,
6388 SingleQuote,
6389 DoubleQuote,
6390 Template,
6391}
6392
6393fn should_prepend_return(code: &str) -> bool {
6404 use ScanState::{Code, DoubleQuote, SingleQuote, Template};
6405
6406 let code = strip_leading_js_comments(code.trim());
6407 if code.is_empty() || starts_with_statement(code) {
6408 return false;
6409 }
6410
6411 let bytes = code.as_bytes();
6412 let mut i = 0;
6413 let mut depth: i32 = 0;
6414 let mut state = ScanState::Code;
6415 let mut template_depths: Vec<i32> = Vec::new();
6418 let mut at_line_start = true;
6420 let mut brackets: Vec<Bracket> = Vec::new();
6423 let mut control_head_closed_at: Option<usize> = None;
6424
6425 let is_return_token = |i: usize| -> bool {
6428 let prev_ok = code[..i]
6429 .chars()
6430 .next_back()
6431 .is_none_or(|c| !is_js_ident_char(c));
6432 prev_ok
6433 && code[i..].starts_with("return")
6434 && code[i + 6..]
6435 .chars()
6436 .next()
6437 .is_none_or(|c| !is_js_ident_char(c))
6438 && !preceded_by_dot(code, i)
6439 };
6440
6441 let mut last_sig: Option<u8> = None;
6444 let mut prev_sig: Option<u8> = None;
6445 let mut last_sig_idx = 0usize;
6446
6447 while i < bytes.len() {
6448 let c = bytes[i];
6449 let terminator = line_terminator_len(bytes, i);
6451 if state == Code && terminator > 0 {
6452 let next_start = i + terminator;
6453 if depth <= 0 && asi_ends_statement(code, next_start, last_sig, prev_sig, last_sig_idx)
6454 {
6455 return false;
6456 }
6457 at_line_start = true;
6458 i = next_start;
6459 continue;
6460 }
6461 match state {
6462 Code => {
6463 let line_comment = bytes[i..].starts_with(b"<!--")
6466 || (at_line_start && bytes[i..].starts_with(b"-->"))
6467 || bytes[i..].starts_with(b"//");
6468 if line_comment {
6469 i = find_line_terminator(bytes, i).unwrap_or(bytes.len());
6470 continue;
6471 }
6472 if c >= 0x80 {
6476 let ch = code.get(i..).and_then(|rest| rest.chars().next());
6477 let len = ch.map_or(1, char::len_utf8);
6478 if !ch.is_some_and(is_js_space) {
6479 at_line_start = false;
6480 prev_sig = last_sig;
6481 last_sig = Some(c);
6482 last_sig_idx = i + len - 1;
6483 }
6484 i += len;
6485 continue;
6486 }
6487 if !c.is_ascii_whitespace() {
6488 at_line_start = false;
6489 }
6490 let in_for_head = brackets.last() == Some(&Bracket::ForHead);
6491 match c {
6492 b'\'' => state = SingleQuote,
6493 b'"' => state = DoubleQuote,
6494 b'`' => state = Template,
6495 b'/' if bytes.get(i + 1) == Some(&b'*') => {
6496 let body_start = i + 2;
6497 let end = code[body_start..]
6498 .find("*/")
6499 .map_or(bytes.len(), |n| body_start + n);
6500 i = (end + 2).min(bytes.len());
6501 if find_line_terminator(&bytes[..end], body_start).is_some() {
6504 if depth <= 0
6505 && asi_ends_statement(code, i, last_sig, prev_sig, last_sig_idx)
6506 {
6507 return false;
6508 }
6509 at_line_start = true;
6510 }
6511 continue;
6512 }
6513 b'/' if last_sig == Some(b'}') => return false,
6516 b'/' if (last_sig == Some(b')')
6517 && control_head_closed_at == Some(last_sig_idx))
6518 || slash_starts_regex(
6519 code,
6520 last_sig,
6521 prev_sig,
6522 last_sig_idx,
6523 in_for_head,
6524 ) =>
6525 {
6526 i = skip_regex_literal(bytes, i);
6529 prev_sig = last_sig;
6530 last_sig = Some(b')'); last_sig_idx = i.saturating_sub(1);
6532 continue;
6533 }
6534 b'(' | b'[' | b'{' => {
6535 depth += 1;
6536 let head = if c == b'(' {
6537 control_head_kind(code, last_sig, last_sig_idx)
6538 } else {
6539 Bracket::Plain
6540 };
6541 brackets.push(head);
6542 }
6543 b')' | b']' | b'}' => {
6544 depth -= 1;
6545 if brackets.pop().is_some_and(|b| b != Bracket::Plain) && c == b')' {
6546 control_head_closed_at = Some(i);
6547 }
6548 if c == b'}' && template_depths.last() == Some(&depth) {
6549 template_depths.pop();
6550 state = Template;
6551 }
6552 }
6553 b';' if depth <= 0 && !strip_leading_js_comments(&code[i + 1..]).is_empty() => {
6556 return false;
6557 }
6558 b'r' if depth <= 0 && is_return_token(i) => return false,
6560 _ => {}
6561 }
6562 if !c.is_ascii_whitespace() {
6563 prev_sig = last_sig;
6564 last_sig = Some(c);
6565 last_sig_idx = i;
6566 }
6567 }
6568 SingleQuote | DoubleQuote | Template => {
6569 let close = match state {
6570 SingleQuote => b'\'',
6571 DoubleQuote => b'"',
6572 _ => b'`',
6573 };
6574 if c == b'\\' {
6575 i += 1;
6576 } else if state == Template && c == b'$' && bytes.get(i + 1) == Some(&b'{') {
6577 template_depths.push(depth);
6578 brackets.push(Bracket::Plain);
6579 depth += 1;
6580 state = Code;
6581 i += 1;
6582 } else if c == close {
6583 state = Code;
6584 prev_sig = last_sig;
6585 last_sig = Some(c);
6586 last_sig_idx = i;
6587 }
6588 }
6589 }
6590 i += 1;
6591 }
6592
6593 true
6594}
6595
6596fn is_js_ident(b: u8) -> bool {
6599 b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || b >= 0x80
6600}
6601
6602fn is_js_space(c: char) -> bool {
6604 c.is_whitespace() || c == '\u{feff}'
6605}
6606
6607fn is_js_ident_char(c: char) -> bool {
6610 if c.is_ascii() {
6611 c.is_ascii_alphanumeric() || c == '_' || c == '$'
6612 } else {
6613 !is_js_space(c) && !matches!(c, '\u{2028}' | '\u{2029}')
6614 }
6615}
6616
6617fn ident_start_before(code: &str, end: usize) -> usize {
6619 let Some(head) = code.get(..end) else {
6620 return end;
6621 };
6622 let mut start = end;
6623 for (idx, c) in head.char_indices().rev() {
6624 if !is_js_ident_char(c) {
6625 break;
6626 }
6627 start = idx;
6628 }
6629 start
6630}
6631
6632fn control_head_kind(code: &str, last_sig: Option<u8>, last_sig_idx: usize) -> Bracket {
6635 if !last_sig.is_some_and(is_js_ident) {
6636 return Bracket::Plain;
6637 }
6638 match js_keyword_ending_at(code, last_sig_idx) {
6639 "if" | "while" | "with" => Bracket::ControlHead,
6640 "for" => Bracket::ForHead,
6641 "await" => {
6642 let before = code[..=last_sig_idx]
6644 .strip_suffix("await")
6645 .unwrap_or("")
6646 .trim_end();
6647 match before.len().checked_sub(1) {
6648 Some(end) if js_keyword_ending_at(code, end) == "for" => Bracket::ForHead,
6649 _ => Bracket::Plain,
6650 }
6651 }
6652 _ => Bracket::Plain,
6653 }
6654}
6655
6656fn preceded_by_dot(code: &str, start: usize) -> bool {
6659 code[..start].trim_end().ends_with('.')
6660}
6661
6662fn js_keyword_ending_at(code: &str, end: usize) -> &str {
6665 let bytes = code.as_bytes();
6666 if !bytes.get(end).copied().is_some_and(is_js_ident) || !code.is_char_boundary(end + 1) {
6667 return "";
6668 }
6669 let start = ident_start_before(code, end + 1);
6670 if start > end || preceded_by_dot(code, start) {
6671 return "";
6672 }
6673 &code[start..=end]
6674}
6675
6676const EXPR_KEYWORDS: &[&str] = &[
6682 "instanceof",
6683 "in",
6684 "typeof",
6685 "void",
6686 "delete",
6687 "new",
6688 "await",
6689 "return",
6690 "case",
6691 "do",
6692 "else",
6693 "throw",
6694];
6695
6696fn is_expr_keyword(word: &str, in_for_head: bool) -> bool {
6700 (word == "of" && in_for_head) || EXPR_KEYWORDS.contains(&word)
6701}
6702
6703fn dot_completes_number(code: &str, dot: usize) -> bool {
6709 let bytes = code.as_bytes();
6710 let start = ident_start_before(code, dot);
6711 let word = &bytes[start..dot];
6712 word.first().is_some_and(u8::is_ascii_digit)
6713 && word.iter().all(|b| b.is_ascii_digit() || *b == b'_')
6714 && !(start > 0 && bytes[start - 1] == b'.')
6715}
6716
6717fn ends_in_postfix(last_sig: Option<u8>, prev_sig: Option<u8>) -> bool {
6719 matches!(
6720 (prev_sig, last_sig),
6721 (Some(b'+'), Some(b'+')) | (Some(b'-'), Some(b'-'))
6722 )
6723}
6724
6725fn asi_ends_statement(
6728 code: &str,
6729 next_start: usize,
6730 last_sig: Option<u8>,
6731 prev_sig: Option<u8>,
6732 last_sig_idx: usize,
6733) -> bool {
6734 let rest = strip_leading_js_comments(&code[next_start.min(code.len())..]);
6735 let Some(&next) = rest.as_bytes().first() else {
6736 return false; };
6738 if rest.starts_with("++") || rest.starts_with("--") {
6740 return true;
6741 }
6742 if ends_in_postfix(last_sig, prev_sig) {
6746 return true;
6747 }
6748 let number_dot = last_sig == Some(b'.') && dot_completes_number(code, last_sig_idx);
6751 let continues_after = (!number_dot
6752 && last_sig.is_some_and(|p| ASI_CONTINUES_AFTER.contains(&p)))
6753 || is_expr_keyword(js_keyword_ending_at(code, last_sig_idx), false);
6754 !(continues_after || ASI_CONTINUES_BEFORE.contains(&next))
6755}
6756
6757fn slash_starts_regex(
6763 code: &str,
6764 last_sig: Option<u8>,
6765 prev_sig: Option<u8>,
6766 last_sig_idx: usize,
6767 in_for_head: bool,
6768) -> bool {
6769 if ends_in_postfix(last_sig, prev_sig) {
6770 return false;
6771 }
6772 match last_sig {
6773 None => true,
6774 Some(b) if b"(,=:[!&|?{};+-*%<>~^".contains(&b) => true,
6775 Some(b) if is_js_ident(b) => {
6776 is_expr_keyword(js_keyword_ending_at(code, last_sig_idx), in_for_head)
6777 }
6778 Some(_) => false,
6779 }
6780}
6781
6782fn skip_regex_literal(bytes: &[u8], start: usize) -> usize {
6784 let mut i = start + 1;
6785 let mut in_class = false;
6786 while i < bytes.len() {
6787 match bytes[i] {
6788 b'\\' => i += 1,
6789 b'[' => in_class = true,
6790 b']' => in_class = false,
6791 b'/' if !in_class => break,
6792 _ if line_terminator_len(bytes, i) > 0 => return i,
6794 _ => {}
6795 }
6796 i += 1;
6797 }
6798 i += 1;
6799 while i < bytes.len() && is_js_ident(bytes[i]) {
6800 i += 1;
6801 }
6802 i
6803}
6804
6805#[cfg(test)]
6806mod prop_tests {
6807 use super::should_prepend_return;
6812 use proptest::prelude::*;
6813
6814 fn ident() -> impl Strategy<Value = String> {
6816 prop_oneof![
6817 Just("a".to_string()),
6818 Just("x".to_string()),
6819 Just("foo".to_string()),
6820 Just("window.x".to_string()),
6821 Just("document.title".to_string()),
6822 Just("obj.prop".to_string()),
6823 Just("arr[0]".to_string()),
6824 Just("localStorage".to_string()),
6825 ]
6826 }
6827
6828 fn bare_expr() -> impl Strategy<Value = String> {
6831 prop_oneof![
6832 ident(),
6833 (ident(), ident()).prop_map(|(a, b)| format!("{a} + {b}")),
6834 (ident(), ident()).prop_map(|(a, b)| format!("{a}({b})")),
6835 ident().prop_map(|a| format!("{a}.length")),
6836 any::<u16>().prop_map(|n| n.to_string()),
6837 ]
6838 }
6839
6840 proptest! {
6841 #[test]
6844 fn never_panics_on_arbitrary_input(s in ".{0,256}") {
6845 let _ = should_prepend_return(&s);
6846 }
6847
6848 #[test]
6850 fn bare_expressions_are_prepended(e in bare_expr()) {
6851 prop_assert!(should_prepend_return(&e), "bare expr not prepended: {e:?}");
6852 }
6853
6854 #[test]
6857 fn semicolon_multistatement_with_return_never_prepended(
6858 setup in bare_expr(), ret in bare_expr()
6859 ) {
6860 let code = format!("{setup}; return {ret}");
6861 prop_assert!(!should_prepend_return(&code), "would corrupt: {code:?}");
6862 }
6863
6864 #[test]
6866 fn newline_explicit_return_never_prepended(pre in bare_expr(), ret in bare_expr()) {
6867 let code = format!("{pre}\nreturn {ret}");
6868 prop_assert!(!should_prepend_return(&code), "explicit return prepended: {code:?}");
6869 }
6870
6871 #[test]
6874 fn statement_after_postfix_or_keyword_property_never_prepended(
6875 a in ident(), b in bare_expr(), op in prop_oneof![Just("++"), Just("--")],
6876 open in prop_oneof![Just("["), Just("(")], prop in prop_oneof![Just("of"), Just("in")]
6877 ) {
6878 let close = if open == "[" { "]" } else { ")" };
6879 let postfix = format!("{a}{op}\n{open}{b}{close}");
6880 prop_assert!(!should_prepend_return(&postfix), "would drop a line: {postfix:?}");
6881 let keyword_prop = format!("{a}.{prop}\n{b}");
6882 prop_assert!(!should_prepend_return(&keyword_prop), "would drop a line: {keyword_prop:?}");
6883 }
6884
6885 #[test]
6888 fn semicolons_and_return_inside_strings_are_ignored(inner in "[a-z0-9;= ]{0,24}") {
6889 let code = format!("'do;not;split return {inner}'");
6891 prop_assert!(should_prepend_return(&code), "string literal mis-split: {code:?}");
6892 }
6893 }
6894}
6895
6896#[cfg(test)]
6897mod tests {
6898 use super::*;
6899
6900 #[cfg(feature = "sqlite")]
6901 #[test]
6902 fn database_path_resolution_rejects_lexical_escape() {
6903 let dir = tempfile::tempdir().unwrap();
6904 let root = dir.path().join("allowed");
6905 std::fs::create_dir(&root).unwrap();
6906 std::fs::File::create(dir.path().join("outside.db")).unwrap();
6907
6908 let err =
6909 VictauriMcpHandler::resolve_existing_db_path(&[root], "../outside.db").unwrap_err();
6910 assert!(err.contains("path traversal"), "unexpected error: {err}");
6911 }
6912
6913 #[cfg(feature = "sqlite")]
6914 #[test]
6915 fn database_path_resolution_accepts_contained_nested_file() {
6916 let dir = tempfile::tempdir().unwrap();
6917 let root = dir.path().join("allowed");
6918 let nested = root.join("nested");
6919 std::fs::create_dir_all(&nested).unwrap();
6920 let db = nested.join("app.db");
6921 std::fs::File::create(&db).unwrap();
6922
6923 let resolved =
6924 VictauriMcpHandler::resolve_existing_db_path(&[root], "nested/app.db").unwrap();
6925 assert_eq!(resolved, std::fs::canonicalize(&db).unwrap());
6928 }
6929
6930 #[cfg(all(feature = "sqlite", unix))]
6931 #[test]
6932 fn database_path_resolution_rejects_symlink_escape() {
6933 use std::os::unix::fs::symlink;
6934
6935 let dir = tempfile::tempdir().unwrap();
6936 let root = dir.path().join("allowed");
6937 std::fs::create_dir(&root).unwrap();
6938 let outside = dir.path().join("outside.db");
6939 std::fs::File::create(&outside).unwrap();
6940 symlink(&outside, root.join("linked.db")).unwrap();
6941
6942 let err =
6943 VictauriMcpHandler::resolve_existing_db_path(std::slice::from_ref(&root), "linked.db")
6944 .unwrap_err();
6945 let miss = VictauriMcpHandler::resolve_existing_db_path(&[root], "absent.db").unwrap_err();
6947 assert!(
6948 err.contains("database not found"),
6949 "unexpected error: {err}"
6950 );
6951 assert_eq!(
6952 err.replace("linked.db", "X"),
6953 miss.replace("absent.db", "X")
6954 );
6955 }
6956
6957 #[cfg(feature = "sqlite")]
6958 #[test]
6959 fn sqlite_identifier_quoting_handles_hostile_table_names() {
6960 let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap();
6961 let conn = rusqlite::Connection::open(file.path()).unwrap();
6962 let name = "odd\"] table";
6963 let identifier = crate::database::quote_sqlite_identifier(name);
6964 conn.execute_batch(&format!(
6965 "CREATE TABLE {identifier} (id INTEGER); INSERT INTO {identifier} VALUES (1);"
6966 ))
6967 .unwrap();
6968 let count: i64 = conn
6969 .query_row(&format!("SELECT count(*) FROM {identifier}"), [], |row| {
6970 row.get(0)
6971 })
6972 .unwrap();
6973 assert_eq!(count, 1);
6974 }
6975
6976 #[test]
6977 fn env_filter_drops_secrets_keeps_safe() {
6978 assert!(is_safe_env_key("HOME"));
6980 assert!(is_safe_env_key("LANG"));
6981 assert!(is_safe_env_key("TAURI_ENV_PLATFORM"));
6982 assert!(is_safe_env_key("VICTAURI_PORT"));
6983 assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY"));
6985 assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"));
6986 assert!(!is_safe_env_key("VICTAURI_AUTH_TOKEN"));
6987 assert!(!is_safe_env_key("VICTAURI_API_KEY"));
6988 assert!(!is_safe_env_key("AWS_SECRET_ACCESS_KEY"));
6990 assert!(!is_safe_env_key("RANDOM_VAR"));
6991 assert!(!is_safe_env_key("TAURI_CUSTOM_THING"));
6994 assert!(!is_safe_env_key("VICTAURI_DB_DSN"));
6997 assert!(!is_safe_env_key("VICTAURI_SIGNING_PASSPHRASE"));
6998 assert!(!is_safe_env_key("VICTAURI_GH_PAT"));
6999 assert!(!is_safe_env_key("VICTAURI_JWT"));
7000 assert!(!is_safe_env_key("VICTAURI_SESSION_ID"));
7001 }
7002
7003 #[test]
7004 fn prepend_return_newline_separated_statements_are_not_wrapped() {
7005 assert!(!should_prepend_return("foo()\nbar()"));
7007 assert!(!should_prepend_return("window.x = 1\nwindow.x + 1"));
7008 assert!(!should_prepend_return("foo() // note\nbar()"));
7009 assert!(should_prepend_return(
7011 "document\n .querySelector('x')\n .textContent"
7012 ));
7013 assert!(should_prepend_return("a +\n b"));
7014 assert!(should_prepend_return("cond\n ? a\n : b"));
7015 assert!(should_prepend_return("[1, 2].map(x =>\n x * 2)"));
7016 assert!(should_prepend_return("`line1\nline2`"));
7017 assert!(should_prepend_return("document.title\n"));
7018 }
7019
7020 fn live_tool_manifest() -> serde_json::Value {
7023 let mut tools = VictauriMcpHandler::tool_router().list_all();
7024 tools.sort_by(|a, b| a.name.cmp(&b.name));
7025 serde_json::Value::Array(
7026 tools
7027 .iter()
7028 .map(|t| {
7029 serde_json::json!({
7030 "name": t.name.as_ref(),
7031 "description": t.description.as_deref().unwrap_or_default(),
7032 })
7033 })
7034 .collect(),
7035 )
7036 }
7037
7038 fn fallback_manifest_path() -> std::path::PathBuf {
7039 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
7040 .join("../victauri-cli/src/tools_fallback.json")
7041 }
7042
7043 #[test]
7044 fn cli_fallback_tool_manifest_matches_the_live_tools() {
7045 let path = fallback_manifest_path();
7049 let Ok(raw) = std::fs::read_to_string(&path) else {
7050 return; };
7052 let live = live_tool_manifest();
7053 if std::env::var_os("VICTAURI_WRITE_FALLBACK").is_some() {
7054 let pretty = serde_json::to_string_pretty(&live).unwrap() + "\n";
7055 std::fs::write(&path, pretty).unwrap();
7056 return;
7057 }
7058 let mut baked: Vec<serde_json::Value> = serde_json::from_str(&raw).unwrap();
7059 baked.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
7060 assert_eq!(
7061 serde_json::Value::Array(baked),
7062 live,
7063 "crates/victauri-cli/src/tools_fallback.json is stale — regenerate it with \
7064 VICTAURI_WRITE_FALLBACK=1 cargo test -p victauri-plugin --lib cli_fallback"
7065 );
7066 }
7067
7068 #[cfg(feature = "sqlite")]
7069 #[test]
7070 fn db_path_outside_roots_gives_no_existence_oracle() {
7071 let root = tempfile::tempdir().unwrap();
7072 let roots = vec![root.path().to_path_buf()];
7073 let outside_existing = std::env::current_exe().unwrap();
7074 let outside_missing = outside_existing.with_file_name("definitely-not-here-7f3a.db");
7075 let e1 = VictauriMcpHandler::resolve_existing_db_path(
7076 &roots,
7077 outside_existing.to_str().unwrap(),
7078 )
7079 .unwrap_err();
7080 let e2 =
7081 VictauriMcpHandler::resolve_existing_db_path(&roots, outside_missing.to_str().unwrap())
7082 .unwrap_err();
7083 assert!(e1.contains("not within an allowed directory"), "{e1}");
7084 assert!(e2.contains("not within an allowed directory"), "{e2}");
7085 assert!(!e1.contains("not found") && !e2.contains("not found"));
7086 let climb = root.path().join("..").join("x.db");
7089 let e3 = VictauriMcpHandler::resolve_existing_db_path(&roots, climb.to_str().unwrap())
7090 .unwrap_err();
7091 assert!(e3.contains("'..' is rejected"), "{e3}");
7092 }
7093
7094 #[test]
7095 fn prepend_return_handles_the_red_team_asi_cases() {
7096 for code in [
7098 "window.x++\nwindow.x",
7099 "window.x--\nwindow.x",
7100 "a\n++b",
7101 "/re/.test(s)\nfoo()",
7102 "s.replace(/\"/g, '')\nfoo()",
7103 "foo()\u{2028}bar()",
7104 "foo()\u{2029}bar()",
7105 ] {
7106 assert!(!should_prepend_return(code), "must not wrap: {code:?}");
7107 }
7108 for code in [
7110 "a instanceof\nB",
7111 "typeof\nx",
7112 "await\nfoo()",
7113 "new\nFoo()",
7114 "a /\nb",
7115 "s.split(/,/)\n.length",
7116 "document.title; // trailing note",
7117 "document.title // trailing note",
7118 "obj.return",
7119 "obj.of\n.length",
7120 "i++ / 2",
7121 "`a${'`'}b`",
7122 ] {
7123 assert!(should_prepend_return(code), "must wrap: {code:?}");
7124 }
7125 for code in ASI_ROUND2_STATEMENT_CASES {
7128 assert!(!should_prepend_return(code), "must not wrap: {code:?}");
7129 }
7130 }
7131
7132 fn run_node(script: &str) -> Option<String> {
7134 let out = std::process::Command::new("node")
7135 .arg("-e")
7136 .arg(script)
7137 .output()
7138 .ok()?;
7139 Some(String::from_utf8_lossy(&out.stdout).into_owned())
7140 }
7141
7142 #[test]
7143 fn log_field_truncation_never_splits_a_surrogate_pair() {
7144 let source = format!(
7147 "[{{ body: 'a'.repeat({}) + '\\u{{1F600}}tail' }}]",
7148 MAX_LOG_FIELD_BYTES - 1
7149 );
7150 let code = trimmed_log_js(&source, 10);
7151 let script = format!(
7152 "const r = (function() {{ {code} }})(); \
7153 console.log('OUT:' + r[0].body.isWellFormed() + ':' + r[0].body.indexOf('bytes truncated'));"
7154 );
7155 let Some(out) = run_node(&script) else {
7156 eprintln!("SKIP: node not installed");
7157 return;
7158 };
7159 let line = out
7160 .lines()
7161 .find_map(|l| l.strip_prefix("OUT:"))
7162 .unwrap_or_else(|| panic!("no output: {out}"));
7163 let (well_formed, marker_at) = line.split_once(':').unwrap();
7164 assert_eq!(
7165 well_formed, "true",
7166 "truncated field is not well-formed UTF-16"
7167 );
7168 assert_ne!(marker_at, "-1", "field was not truncated");
7169 }
7170
7171 const ASI_ROUND2_STATEMENT_CASES: &[&str] = &[
7174 "i++ / 2; f()",
7175 "x = {} / 1; f()",
7176 "i++\n[f()]",
7177 "i++\n(f)()",
7178 "i--\n[f()]",
7179 "i--\n(f)()",
7180 "obj.of\nf()",
7181 "obj.in\nf()",
7182 "if\t(true) f()",
7183 "if\n(true) f()",
7184 "const\ny = 5; f()",
7185 "outer: for (const a of [1]) { f() }",
7186 "outer:\nfor (const a of [1]) { f() }",
7187 "`a${'`'}b`; f()",
7188 "`${'`'}`\nf()",
7189 "f() <!-- don't\nf()",
7190 "f()\n--> it's a comment\nf()",
7191 "a = /[/]/\nf()",
7192 "typeof x / 2; f()",
7193 ];
7194
7195 const ASI_ROUND4_STATEMENT_CASES: &[&str] = &[
7198 "x = 1 /*\n*/ f()",
7200 "x = 1 /*\r*/ f()",
7201 "x = 1 /*\u{2028}*/ f()",
7202 "x = 1 /* a\n b */ f()",
7203 "x = 1\rf()",
7205 "x = 1\r\nf()",
7206 "x = 1 // note\rf()",
7207 "x = 1 // note\u{2028}f()",
7208 "f() <!-- c\rf()",
7209 "f()\r--> c\rf()",
7210 "x = /a/g\rf()",
7211 "x = 1.\nf()",
7213 "x = 1_0.\rf()",
7214 "x = 10.\n\nf()",
7215 "x = typeof yield\nf()",
7217 "x = typeof of\nf()",
7218 ];
7219
7220 const ASI_ROUND5_STATEMENT_CASES: &[&str] = &[
7226 "(function(){ if (a) /'/.test('q') })(); f()",
7227 "(function(){ if (a) /\\(/.test('q') })(); f()",
7228 "(function(){ if (a) /\"/.test('q') })(); f()",
7229 "(function(){ if (a) /`/.test('q') })(); f()",
7230 "(function(){ if (a) /[(]/.test('q') })(); f()",
7231 "(function(){ if (a) /\\[/.test('q') })(); f()",
7232 "(function(){ if (a) /\\{/.test('q') })(); f()",
7233 "(function(){ if ((a)) /'/.test('q') })(); f()",
7234 "(function(){ while (a) /'/.test('q') })(); f()",
7235 "(function(){ for (;a;) /'/.test('q') })(); f()",
7236 "(function(){ with (obj) /'/.test('q') })(); f()",
7237 "(async function(){ for await (const q of []) /'/.test('q') })(); f()",
7238 "(() => { if (a) /'/.test('q') })(); f()",
7239 "Math.abs(of / 2); f()",
7240 "Math.abs(of / 2); f() / 1",
7241 "[of / 2]; f()",
7242 "énew / 2; f() / 1",
7243 "x = énew / 2; f()",
7244 "x = \u{e9}typeof / 2; f()",
7245 ];
7246
7247 #[test]
7248 fn prepend_return_round5_shapes() {
7249 for code in ASI_ROUND5_STATEMENT_CASES {
7250 assert!(!should_prepend_return(code), "must not wrap: {code:?}");
7251 }
7252 for code in [
7253 "(x) / 2 / 1",
7254 "f(x) / 2",
7255 "f(of / 2)",
7256 "[1, 2].map(of => of / 2)",
7257 "énew / 2",
7258 "if_ (a) / 2",
7259 "obj.if (a) / 2",
7260 "(a) / 2; ",
7261 ] {
7262 assert!(should_prepend_return(code), "must wrap: {code:?}");
7263 }
7264 }
7265
7266 #[test]
7268 fn prepend_return_round4_shapes() {
7269 for code in ASI_ROUND4_STATEMENT_CASES {
7270 assert!(!should_prepend_return(code), "must not wrap: {code:?}");
7271 }
7272 for code in [
7273 "x /* no line break */ + 1",
7274 "x /*\n*/ + 1",
7275 "x +\r1",
7276 "x\r.toString()",
7277 "a instanceof\r\nB",
7278 "1.5\n.toFixed(1)",
7279 "obj.of.\nlength",
7280 "0x10.\ntoString()",
7281 "1e3.\ntoFixed(0)",
7282 "(1).\ntoFixed(0)",
7283 "document.title // trailing note\r",
7284 "-->x\ndocument.title",
7285 "<!-- x\rdocument.title",
7286 "// lead\u{2028}document.title",
7287 "[1].map(y => { for (const z of\n[2]) {} })",
7288 ] {
7289 assert!(should_prepend_return(code), "must wrap: {code:?}");
7290 }
7291 }
7292
7293 #[test]
7294 fn line_terminators_and_number_dots_are_recognized() {
7295 for (s, at, len) in [
7296 ("a\nb", 1, 1),
7297 ("a\rb", 1, 1),
7298 ("a\r\nb", 1, 2),
7299 ("a\u{2028}b", 1, 3),
7300 ("a\u{2029}b", 1, 3),
7301 ("a b", 1, 0),
7302 ("a\u{2027}b", 1, 0),
7303 ] {
7304 assert_eq!(line_terminator_len(s.as_bytes(), at), len, "{s:?}");
7305 }
7306 for (code, complete) in [
7307 ("1.", true),
7308 ("x = 10.", true),
7309 ("1_000.", true),
7310 ("a1.", false),
7311 ("x.", false),
7312 ("0x1.", false),
7313 ("1e3.", false),
7314 ("1n.", false),
7315 ("1.5.", false),
7316 ("(1).", false),
7317 ("1 .", false),
7318 ] {
7319 assert_eq!(
7320 dot_completes_number(code, code.len() - 1),
7321 complete,
7322 "{code:?}"
7323 );
7324 }
7325 }
7326
7327 #[test]
7332 fn prepend_return_decisions_run_every_statement_in_node() {
7333 let mut cases: Vec<(&str, &str, Vec<&str>)> = ASI_ROUND2_STATEMENT_CASES
7334 .iter()
7335 .chain(ASI_ROUND4_STATEMENT_CASES)
7336 .chain(ASI_ROUND5_STATEMENT_CASES)
7337 .map(|c| {
7338 let n = c.matches("f()").count() + c.matches("(f)()").count();
7339 (*c, "undefined", vec!["f"; n])
7340 })
7341 .collect();
7342 cases.extend([
7343 ("obj.return", "\"RET\"", vec![]),
7344 ("obj.of", "\"OF\"", vec![]),
7345 ("obj.in\n.length", "2", vec![]),
7346 ("i++ / 2", "0.5", vec![]),
7347 ("`a${'`'}b`", "\"a`b\"", vec![]),
7348 ("f() <!-- trailing html comment", "1", vec!["f"]),
7349 ("f()\n+ 1", "2", vec!["f"]),
7350 ("document", "\"doc\"", vec![]),
7351 ("x +\r1", "1", vec![]),
7352 ("x /*\n*/ + 1", "1", vec![]),
7353 ("obj.of.\nlength", "2", vec![]),
7354 ("0x10.\ntoString()", "\"16\"", vec![]),
7355 ("-->x\ndocument", "\"doc\"", vec![]),
7356 ]);
7357 let bodies: Vec<String> = cases
7358 .iter()
7359 .map(|(code, _, _)| {
7360 let body = strip_leading_js_comments(code.trim());
7361 if should_prepend_return(body) {
7362 format!("return {body}")
7363 } else {
7364 code.trim().to_string()
7365 }
7366 })
7367 .collect();
7368 let mut file = tempfile::NamedTempFile::new().unwrap();
7369 std::io::Write::write_all(
7370 &mut file,
7371 serde_json::to_string(&bodies).unwrap().as_bytes(),
7372 )
7373 .unwrap();
7374 let runner = r"
7375 const vm = require('vm');
7376 const bodies = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'));
7377 (async () => {
7378 const out = [];
7379 for (const body of bodies) {
7380 const log = [];
7381 globalThis.__log = log;
7382 const src = '(async () => { const log = globalThis.__log; const f = () => log.push(\'f\');'
7383 + ' let i = 1, x = 0, a, of = 4, énew = 2, étypeof = 3; const document = \'doc\';'
7384 + ' const obj = { of: \'OF\', in: \'IN\', return: \'RET\' };\n' + body + '\n })()';
7385 let ret, err = null;
7386 try { ret = await vm.runInThisContext(src); } catch (e) { err = e.name + ': ' + e.message; }
7387 out.push({ ret: ret === undefined ? 'undefined' : JSON.stringify(ret), log, err });
7388 }
7389 console.log('ASI_RESULTS:' + JSON.stringify(out));
7390 })();
7391 ";
7392 let Ok(output) = std::process::Command::new("node")
7393 .arg("-e")
7394 .arg(runner)
7395 .arg(file.path())
7396 .output()
7397 else {
7398 eprintln!("SKIP: node not installed");
7399 return;
7400 };
7401 let stdout = String::from_utf8_lossy(&output.stdout);
7402 let line = stdout
7403 .lines()
7404 .find_map(|l| l.strip_prefix("ASI_RESULTS:"))
7405 .unwrap_or_else(|| {
7406 panic!(
7407 "no results: {stdout}\n{}",
7408 String::from_utf8_lossy(&output.stderr)
7409 )
7410 });
7411 let results: Vec<serde_json::Value> = serde_json::from_str(line).unwrap();
7412 let mut failures = Vec::new();
7413 for (((code, want_ret, want_log), body), got) in cases.iter().zip(&bodies).zip(&results) {
7414 let got_log: Vec<&str> = got["log"]
7415 .as_array()
7416 .unwrap()
7417 .iter()
7418 .filter_map(|v| v.as_str())
7419 .collect();
7420 if !got["err"].is_null() || got["ret"] != *want_ret || got_log != *want_log {
7421 failures.push(format!(
7422 "{code:?} (ran as {body:?}): got ret={} log={got_log:?} err={}; want ret={want_ret} log={want_log:?}",
7423 got["ret"], got["err"]
7424 ));
7425 }
7426 }
7427 assert!(failures.is_empty(), "\n{}", failures.join("\n"));
7428 }
7429
7430 #[test]
7431 fn prepend_return_sees_past_leading_comments() {
7432 assert!(!should_prepend_return("// setup\nconst x = 1; x"));
7433 assert!(!should_prepend_return("/* c */ if (a) b()"));
7434 assert!(should_prepend_return("// read it\ndocument.title"));
7435 assert_eq!(
7436 strip_leading_js_comments("// a\n/* b */ document.title"),
7437 "document.title"
7438 );
7439 assert_eq!(strip_leading_js_comments("// only a comment"), "");
7440 }
7441
7442 #[test]
7443 fn prepend_return_bare_expressions() {
7444 assert!(should_prepend_return("document.title"));
7445 assert!(should_prepend_return("5 + 5"));
7446 assert!(should_prepend_return("\"justexpr\""));
7447 assert!(should_prepend_return("await fetch('/x')"));
7448 assert!(should_prepend_return(
7449 "document.querySelectorAll('a').length"
7450 ));
7451 assert!(should_prepend_return("x ? a : b"));
7452 assert!(should_prepend_return("document.title;"));
7454 assert!(should_prepend_return("'a;b;c'"));
7456 assert!(should_prepend_return("\"x;y\".length"));
7457 assert!(should_prepend_return("(()=>{window.x=5; return 'ok'})()"));
7459 }
7460
7461 #[test]
7462 fn no_prepend_for_statement_blocks() {
7463 assert!(!should_prepend_return(
7465 "localStorage.setItem('k','v'); return localStorage.getItem('k')"
7466 ));
7467 assert!(!should_prepend_return(
7468 "window.scrollTo(0,50); return window.scrollY"
7469 ));
7470 assert!(!should_prepend_return("console.log('x'); return 123"));
7471 assert!(!should_prepend_return("window.__z=7; return 'ok'"));
7472 assert!(!should_prepend_return("window.x = 5\nreturn window.x"));
7474 }
7475
7476 #[test]
7477 fn no_prepend_for_statement_keywords() {
7478 assert!(!should_prepend_return("return 42"));
7479 assert!(!should_prepend_return("const x = 1; return x"));
7480 assert!(!should_prepend_return("let y = 2"));
7481 assert!(!should_prepend_return("var z = 3"));
7482 assert!(!should_prepend_return("if (x) { return 1 }"));
7483 assert!(!should_prepend_return("for (const x of y) doThing(x)"));
7484 assert!(!should_prepend_return("throw new Error('x')"));
7485 assert!(!should_prepend_return("function f(){}"));
7486 assert!(!should_prepend_return("{ a: 1 }")); }
7488
7489 #[test]
7490 fn empty_code_no_prepend() {
7491 assert!(!should_prepend_return(""));
7492 assert!(!should_prepend_return(" "));
7493 }
7494
7495 #[test]
7496 fn envelope_unwrap_value() {
7497 assert_eq!(
7498 unwrap_eval_envelope(r#"{"__victauri_ok":"4DA","__victauri_type":"value"}"#.into()),
7499 Ok("\"4DA\"".to_string())
7500 );
7501 assert_eq!(
7502 unwrap_eval_envelope(r#"{"__victauri_ok":42,"__victauri_type":"value"}"#.into()),
7503 Ok("42".to_string())
7504 );
7505 }
7506
7507 #[test]
7508 fn envelope_unwrap_undefined_null() {
7509 assert_eq!(
7510 unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#.into()),
7511 Ok("undefined".to_string())
7512 );
7513 assert_eq!(
7514 unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"null"}"#.into()),
7515 Ok("null".to_string())
7516 );
7517 }
7518
7519 #[test]
7520 fn envelope_unwrap_error() {
7521 let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into());
7522 assert!(r.unwrap_err().message.contains("boom"));
7523 }
7524
7525 #[test]
7526 fn envelope_unwrap_deeply_nested_does_not_leak() {
7527 let mut value = String::from("0");
7531 for _ in 0..300 {
7532 value = format!("{{\"n\":{value}}}");
7533 }
7534 let raw = format!(r#"{{"__victauri_ok":{value},"__victauri_type":"value"}}"#);
7535 let out = unwrap_eval_envelope(raw).unwrap();
7536 assert!(
7537 out.starts_with(r#"{"n":"#),
7538 "deep value should be unwrapped, got: {}",
7539 &out[..out.len().min(40)]
7540 );
7541 assert!(
7542 !out.contains("__victauri_ok"),
7543 "envelope must not leak into the result"
7544 );
7545 }
7546
7547 #[test]
7548 fn js_string_simple() {
7549 assert_eq!(js_string("hello"), "\"hello\"");
7550 }
7551
7552 #[test]
7553 fn js_string_single_quotes() {
7554 let result = js_string("it's a test");
7555 assert!(result.contains("it's a test"));
7556 }
7557
7558 #[test]
7559 fn js_string_double_quotes() {
7560 let result = js_string(r#"say "hello""#);
7561 assert!(result.contains(r#"\""#));
7562 }
7563
7564 #[test]
7565 fn js_string_backslashes() {
7566 let result = js_string(r"path\to\file");
7567 assert!(result.contains(r"\\"));
7568 }
7569
7570 #[test]
7571 fn js_string_newlines_and_tabs() {
7572 let result = js_string("line1\nline2\ttab");
7573 assert!(result.contains(r"\n"));
7574 assert!(result.contains(r"\t"));
7575 assert!(!result.contains('\n'));
7576 }
7577
7578 #[test]
7579 fn js_string_null_bytes() {
7580 let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
7581 let result = js_string(&input);
7582 assert!(result.contains("\\u0000"));
7584 assert!(!result.contains('\0'));
7585 }
7586
7587 #[test]
7588 fn js_string_template_literal_injection() {
7589 let result = js_string("`${alert(1)}`");
7590 assert!(result.starts_with('"'));
7593 assert!(result.ends_with('"'));
7594 }
7595
7596 #[test]
7597 fn js_string_unicode_separators() {
7598 let result = js_string("a\u{2028}b\u{2029}c");
7603 let decoded: String = serde_json::from_str(&result).unwrap();
7605 assert_eq!(decoded, "a\u{2028}b\u{2029}c");
7606 }
7607
7608 #[test]
7609 fn js_string_empty() {
7610 assert_eq!(js_string(""), "\"\"");
7611 }
7612
7613 #[test]
7614 fn js_string_html_script_close() {
7615 let result = js_string("</script><img onerror=alert(1)>");
7617 assert!(result.starts_with('"'));
7618 let decoded: String = serde_json::from_str(&result).unwrap();
7620 assert_eq!(decoded, "</script><img onerror=alert(1)>");
7621 }
7622
7623 #[test]
7624 fn js_string_very_long() {
7625 let long = "a".repeat(100_000);
7626 let result = js_string(&long);
7627 assert!(result.len() >= 100_002); }
7629
7630 #[test]
7633 fn url_allows_http() {
7634 assert!(validate_url("http://example.com", false).is_ok());
7635 }
7636
7637 #[test]
7638 fn url_allows_https() {
7639 assert!(validate_url("https://example.com/path?q=1", false).is_ok());
7640 }
7641
7642 #[test]
7643 fn url_allows_http_localhost() {
7644 assert!(validate_url("http://localhost:3000", false).is_ok());
7645 }
7646
7647 #[test]
7648 fn url_blocks_file_by_default() {
7649 let err = validate_url("file:///etc/passwd", false).unwrap_err();
7650 assert!(err.contains("file"), "error should mention the file scheme");
7651 }
7652
7653 #[test]
7654 fn url_allows_file_when_opted_in() {
7655 assert!(validate_url("file:///tmp/test.html", true).is_ok());
7656 }
7657
7658 #[test]
7659 fn url_blocks_javascript() {
7660 assert!(validate_url("javascript:alert(1)", false).is_err());
7661 }
7662
7663 #[test]
7664 fn url_blocks_javascript_case_insensitive() {
7665 assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
7666 }
7667
7668 #[test]
7669 fn url_blocks_data_scheme() {
7670 assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
7671 }
7672
7673 #[test]
7674 fn url_blocks_vbscript() {
7675 assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
7676 }
7677
7678 #[test]
7679 fn url_rejects_invalid() {
7680 assert!(validate_url("not a url at all", false).is_err());
7681 }
7682
7683 #[test]
7684 fn url_strips_control_chars() {
7685 let input = format!("http://example{}com", '\0');
7687 assert!(validate_url(&input, false).is_ok());
7688 }
7689
7690 #[test]
7693 fn css_color_valid_hex() {
7694 assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
7695 assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
7696 assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
7697 }
7698
7699 #[test]
7700 fn css_color_valid_rgb() {
7701 assert_eq!(
7702 sanitize_css_color("rgb(255, 0, 0)").unwrap(),
7703 "rgb(255, 0, 0)"
7704 );
7705 assert_eq!(
7706 sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
7707 "rgba(0, 0, 0, 0.5)"
7708 );
7709 }
7710
7711 #[test]
7712 fn css_color_valid_named() {
7713 assert_eq!(sanitize_css_color("red").unwrap(), "red");
7714 assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
7715 }
7716
7717 #[test]
7718 fn css_color_valid_hsl() {
7719 assert_eq!(
7720 sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
7721 "hsl(120, 50%, 50%)"
7722 );
7723 }
7724
7725 #[test]
7726 fn css_color_rejects_too_long() {
7727 let long = "a".repeat(101);
7728 assert!(sanitize_css_color(&long).is_err());
7729 }
7730
7731 #[test]
7732 fn css_color_rejects_backslash_escapes() {
7733 assert!(sanitize_css_color(r"red\00").is_err());
7734 assert!(sanitize_css_color(r"\72\65\64").is_err());
7735 }
7736
7737 #[test]
7738 fn css_color_rejects_url_injection() {
7739 assert!(sanitize_css_color("url(http://evil.com)").is_err());
7740 assert!(sanitize_css_color("URL(http://evil.com)").is_err());
7741 }
7742
7743 #[test]
7744 fn css_color_rejects_expression_injection() {
7745 assert!(sanitize_css_color("expression(alert(1))").is_err());
7746 assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
7747 }
7748
7749 #[test]
7750 fn css_color_rejects_import() {
7751 assert!(sanitize_css_color("@import url(evil.css)").is_err());
7752 }
7753
7754 #[test]
7755 fn css_color_rejects_semicolons_and_braces() {
7756 assert!(sanitize_css_color("red; background: url(evil)").is_err());
7757 assert!(sanitize_css_color("red} body { color: blue").is_err());
7758 }
7759
7760 #[test]
7761 fn css_color_rejects_special_chars() {
7762 assert!(sanitize_css_color("red<script>").is_err());
7763 assert!(sanitize_css_color("red\"onload=alert").is_err());
7764 assert!(sanitize_css_color("red'onclick=alert").is_err());
7765 }
7766
7767 #[test]
7768 fn css_color_trims_whitespace() {
7769 assert_eq!(sanitize_css_color(" red ").unwrap(), "red");
7770 }
7771
7772 #[test]
7773 fn css_color_empty_string() {
7774 assert_eq!(sanitize_css_color("").unwrap(), "");
7775 }
7776}
7777
7778#[cfg(test)]
7787mod authz_dispatch_tests {
7788 use super::*;
7789 use crate::bridge::WebviewBridge;
7790 use crate::privacy::PrivacyConfig;
7791 use std::collections::{HashMap, HashSet};
7792 use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
7793
7794 struct RejectingBridge;
7798
7799 impl WebviewBridge for RejectingBridge {
7800 fn eval_webview(&self, _label: Option<&str>, _script: &str) -> Result<(), String> {
7801 Err("eval rejected in authz dispatch test".to_string())
7802 }
7803 fn get_window_states(&self, _label: Option<&str>) -> Vec<WindowState> {
7804 Vec::new()
7805 }
7806 fn list_window_labels(&self) -> Vec<String> {
7807 Vec::new()
7808 }
7809 fn get_native_handle(&self, _label: Option<&str>) -> Result<isize, String> {
7810 Err("no handle".to_string())
7811 }
7812 fn manage_window(&self, _label: Option<&str>, _action: &str) -> Result<String, String> {
7813 Err("no window".to_string())
7814 }
7815 fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
7816 Ok(())
7817 }
7818 fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
7819 Ok(())
7820 }
7821 fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
7822 Ok(())
7823 }
7824 }
7825
7826 fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
7827 Arc::new(VictauriState {
7828 event_log: EventLog::new(1000),
7829 registry: CommandRegistry::new(),
7830 port: std::sync::atomic::AtomicU16::new(0),
7831 pending_evals: Arc::new(Mutex::new(HashMap::new())),
7832 recorder: EventRecorder::new(1000),
7833 privacy,
7834 eval_timeout: std::time::Duration::from_millis(100),
7835 shutdown_tx: tokio::sync::watch::channel(false).0,
7836 started_at: std::time::Instant::now(),
7837 tool_invocations: std::sync::atomic::AtomicU64::new(0),
7838 allow_file_navigation: false,
7839 command_timings: crate::introspection::CommandTimings::new(),
7840 fault_registry: crate::introspection::FaultRegistry::new(),
7841 contract_store: crate::introspection::ContractStore::new(),
7842 startup_timeline: crate::introspection::StartupTimeline::new(),
7843 event_bus: crate::introspection::EventBusMonitor::default(),
7844 task_tracker: crate::introspection::TaskTracker::new(),
7845 bridge_ready: std::sync::atomic::AtomicBool::new(true),
7846 bridge_notify: tokio::sync::Notify::new(),
7847 db_search_paths: Vec::new(),
7848 screencast: Arc::new(crate::screencast::Screencast::default()),
7849 probes: crate::introspection::AppStateProbes::default(),
7850 drain_watermarks: crate::introspection::DrainWatermarks::default(),
7851 page_loads: crate::introspection::PageLoads::default(),
7852 server_runtime: std::sync::OnceLock::new(),
7853 backend_logs: std::sync::Arc::new(crate::backend_logs::LogBuffer::default()),
7854 })
7855 }
7856
7857 fn handler(privacy: PrivacyConfig) -> VictauriMcpHandler {
7858 VictauriMcpHandler::new(state_with(privacy), Arc::new(RejectingBridge))
7859 }
7860
7861 fn is_privacy_blocked(r: &CallToolResult) -> bool {
7863 r.is_error == Some(true)
7864 && r.content.iter().any(|c| {
7865 matches!(c, ContentBlock::Text(t)
7866 if t.text.contains("disabled by privacy configuration"))
7867 })
7868 }
7869
7870 async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
7871 match h.execute_tool(tool, args).await {
7872 Ok(r) => r,
7873 Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
7874 }
7875 }
7876
7877 fn schema_actions<T: schemars::JsonSchema>() -> Vec<String> {
7879 fn collect(v: &serde_json::Value, out: &mut Vec<String>) {
7880 if let Some(values) = v.get("enum").and_then(serde_json::Value::as_array) {
7881 out.extend(values.iter().filter_map(|x| x.as_str().map(String::from)));
7882 }
7883 if let Some(c) = v.get("const").and_then(serde_json::Value::as_str) {
7884 out.push(c.to_string());
7885 }
7886 for key in ["oneOf", "anyOf"] {
7887 for sub in v
7888 .get(key)
7889 .and_then(serde_json::Value::as_array)
7890 .into_iter()
7891 .flatten()
7892 {
7893 collect(sub, out);
7894 }
7895 }
7896 }
7897 let schema = serde_json::to_value(schemars::schema_for!(T)).unwrap();
7898 let mut action = schema["properties"]["action"].clone();
7899 if let Some(target) = action.get("$ref").and_then(serde_json::Value::as_str) {
7900 let name = target.rsplit('/').next().unwrap();
7901 action = schema["$defs"][name].clone();
7902 }
7903 let mut out = Vec::new();
7904 collect(&action, &mut out);
7905 out
7906 }
7907
7908 #[test]
7913 fn every_action_variant_has_a_capability() {
7914 let tools: &[(&str, Vec<String>)] = &[
7915 ("interact", schema_actions::<InteractParams>()),
7916 ("input", schema_actions::<InputParams>()),
7917 ("window", schema_actions::<WindowParams>()),
7918 ("storage", schema_actions::<StorageParams>()),
7919 ("navigate", schema_actions::<NavigateParams>()),
7920 ("recording", schema_actions::<RecordingParams>()),
7921 ("inspect", schema_actions::<InspectParams>()),
7922 ("css", schema_actions::<CssParams>()),
7923 ("route", schema_actions::<RouteParams>()),
7924 ("trace", schema_actions::<TraceParams>()),
7925 ("animation", schema_actions::<AnimationParams>()),
7926 ("logs", schema_actions::<LogsParams>()),
7927 ("introspect", schema_actions::<IntrospectParams>()),
7928 ("fault", schema_actions::<FaultParams>()),
7929 ("explain", schema_actions::<ExplainParams>()),
7930 ];
7931 for (tool, actions) in tools {
7932 assert!(
7933 !actions.is_empty(),
7934 "{tool}: no actions read from its schema"
7935 );
7936 for action in actions {
7937 assert!(
7938 authz::action_capability(tool, action).is_some(),
7939 "{tool}.{action} is accepted by the parser but has no capability"
7940 );
7941 }
7942 }
7943 }
7944
7945 #[test]
7948 fn disable_tools_name_list_matches_the_live_tools() {
7949 let mut live: Vec<String> = VictauriMcpHandler::tool_router()
7950 .list_all()
7951 .into_iter()
7952 .map(|t| t.name.to_string())
7953 .filter(|n| !authz::is_compound_tool(n))
7954 .collect();
7955 let mut listed: Vec<String> = authz::STANDALONE_TOOLS
7956 .iter()
7957 .map(|s| (*s).to_string())
7958 .collect();
7959 live.sort_unstable();
7960 listed.sort_unstable();
7961 assert_eq!(live, listed);
7962 for t in VictauriMcpHandler::tool_router().list_all() {
7963 assert!(authz::is_known_name(&t.name), "{}", t.name);
7964 }
7965 }
7966
7967 #[tokio::test]
7973 async fn non_string_action_cannot_slip_past_the_gate() {
7974 let mut full_minus_go_to = PrivacyConfig::default();
7975 full_minus_go_to
7976 .disabled_tools
7977 .insert("navigate.go_to".to_string());
7978 for privacy in [crate::privacy::test_privacy_config(), full_minus_go_to] {
7979 let h = handler(privacy);
7980 for args in [
7981 serde_json::json!({"action": {"go_to": null}, "url": "https://evil.example"}),
7982 serde_json::json!(["go_to", "https://evil.example", null, null, null, null]),
7983 serde_json::json!({"action": 0, "url": "https://evil.example"}),
7984 ] {
7985 match h.execute_tool("navigate", args.clone()).await {
7986 Err(rest::ToolCallError::InvalidParams(_)) => {}
7987 Ok(r) => panic!("{args} reached dispatch: {:?}", r.content),
7988 Err(rest::ToolCallError::UnknownTool(t)) => panic!("{args}: unknown tool {t}"),
7989 }
7990 }
7991 }
7992 }
7993
7994 #[tokio::test]
7997 async fn observe_blocks_mutations_and_eval_through_dispatch() {
7998 let h = handler(crate::privacy::observe_privacy_config());
7999 let blocked: &[(&str, serde_json::Value)] = &[
8000 ("eval_js", serde_json::json!({"code": "1"})),
8001 (
8002 "wait_for",
8003 serde_json::json!({"condition": "expression", "value": "true"}),
8004 ),
8005 ("screenshot", serde_json::json!({})),
8006 ("invoke_command", serde_json::json!({"command": "greet"})),
8007 ("verify_state", serde_json::json!({"frontend_expr": "1"})),
8008 (
8009 "assert_semantic",
8010 serde_json::json!({"expression": "1", "condition": "truthy"}),
8011 ),
8012 (
8013 "interact",
8014 serde_json::json!({"action": "click", "ref_id": "e1"}),
8015 ),
8016 (
8017 "input",
8018 serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
8019 ),
8020 (
8021 "storage",
8022 serde_json::json!({"action": "set", "key": "k", "value": "v"}),
8023 ),
8024 (
8025 "storage",
8026 serde_json::json!({"action": "delete", "key": "k"}),
8027 ),
8028 (
8029 "window",
8030 serde_json::json!({"action": "manage", "manage_action": "close"}),
8031 ),
8032 (
8033 "window",
8034 serde_json::json!({"action": "set_title", "title": "x"}),
8035 ),
8036 (
8037 "navigate",
8038 serde_json::json!({"action": "go_to", "url": "https://e.com"}),
8039 ),
8040 (
8041 "css",
8042 serde_json::json!({"action": "inject", "css": "body{}"}),
8043 ),
8044 ("route", serde_json::json!({"action": "clear_all"})),
8045 ("recording", serde_json::json!({"action": "start"})),
8046 ("recording", serde_json::json!({"action": "replay"})),
8047 ("logs", serde_json::json!({"action": "clear"})),
8048 (
8049 "fault",
8050 serde_json::json!({"action": "inject", "command": "x", "fault_type": "error"}),
8051 ),
8052 (
8053 "introspect",
8054 serde_json::json!({"action": "command_timings"}),
8055 ),
8056 ];
8057 for (tool, args) in blocked {
8058 let r = call(&h, tool, args.clone()).await;
8059 assert!(
8060 is_privacy_blocked(&r),
8061 "Observe must block {tool} {args} at dispatch, got: {:?}",
8062 r.content
8063 );
8064 }
8065 }
8066
8067 #[tokio::test]
8068 async fn observe_allows_read_only_through_dispatch() {
8069 let h = handler(crate::privacy::observe_privacy_config());
8070 let allowed: &[(&str, serde_json::Value)] = &[
8073 ("get_registry", serde_json::json!({})),
8074 ("get_memory_stats", serde_json::json!({})),
8075 ("window", serde_json::json!({"action": "list"})),
8076 ("logs", serde_json::json!({"action": "ipc"})),
8077 (
8078 "inspect",
8079 serde_json::json!({"action": "get_styles", "ref_id": "e1"}),
8080 ),
8081 ];
8082 for (tool, args) in allowed {
8083 let r = call(&h, tool, args.clone()).await;
8084 assert!(
8085 !is_privacy_blocked(&r),
8086 "Observe must allow {tool} {args} at dispatch (blocked unexpectedly)"
8087 );
8088 }
8089 }
8090
8091 #[tokio::test]
8094 async fn test_profile_dispatch_boundaries() {
8095 let h = handler(crate::privacy::test_privacy_config());
8096 for (tool, args) in [
8098 (
8099 "interact",
8100 serde_json::json!({"action": "click", "ref_id": "e1"}),
8101 ),
8102 (
8103 "input",
8104 serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
8105 ),
8106 (
8107 "storage",
8108 serde_json::json!({"action": "set", "key": "k", "value": "v"}),
8109 ),
8110 ("navigate", serde_json::json!({"action": "go_back"})),
8111 ("recording", serde_json::json!({"action": "start"})),
8112 ("logs", serde_json::json!({"action": "clear"})),
8113 ] {
8114 let r = call(&h, tool, args.clone()).await;
8115 assert!(!is_privacy_blocked(&r), "Test must allow {tool} {args}");
8116 }
8117 for (tool, args) in [
8119 ("eval_js", serde_json::json!({"code": "1"})),
8120 (
8121 "wait_for",
8122 serde_json::json!({"condition": "expression", "value": "true"}),
8123 ),
8124 ("verify_state", serde_json::json!({"frontend_expr": "1"})),
8125 (
8126 "navigate",
8127 serde_json::json!({"action": "go_to", "url": "https://e.com"}),
8128 ),
8129 ("recording", serde_json::json!({"action": "replay"})),
8130 (
8131 "route",
8132 serde_json::json!({"action": "add", "pattern": "x"}),
8133 ),
8134 ("css", serde_json::json!({"action": "inject", "css": "x"})),
8135 (
8136 "window",
8137 serde_json::json!({"action": "set_title", "title": "x"}),
8138 ),
8139 ] {
8140 let r = call(&h, tool, args.clone()).await;
8141 assert!(is_privacy_blocked(&r), "Test must block {tool} {args}");
8142 }
8143 }
8144
8145 #[tokio::test]
8150 async fn disabling_bare_compound_tool_blocks_all_actions() {
8151 let cfg = PrivacyConfig {
8152 disabled_tools: HashSet::from(["recording".to_string()]),
8153 ..Default::default()
8154 }; let h = handler(cfg);
8156 for action in ["start", "stop", "replay", "import", "export"] {
8157 let r = call(&h, "recording", serde_json::json!({"action": action})).await;
8158 assert!(
8159 is_privacy_blocked(&r),
8160 "disabling bare `recording` must block recording.{action}"
8161 );
8162 }
8163 }
8164
8165 #[tokio::test]
8166 async fn disabling_specific_action_is_honored_at_dispatch() {
8167 let cfg = PrivacyConfig {
8171 disabled_tools: HashSet::from([
8172 "route.clear".to_string(),
8173 "route.clear_all".to_string(),
8174 ]),
8175 ..Default::default()
8176 }; let h = handler(cfg);
8178
8179 let blocked = call(&h, "route", serde_json::json!({"action": "clear", "id": 1})).await;
8180 assert!(is_privacy_blocked(&blocked), "route.clear must be blocked");
8181 let blocked_all = call(&h, "route", serde_json::json!({"action": "clear_all"})).await;
8182 assert!(
8183 is_privacy_blocked(&blocked_all),
8184 "route.clear_all must be blocked"
8185 );
8186
8187 let allowed = call(&h, "route", serde_json::json!({"action": "list"})).await;
8189 assert!(
8190 !is_privacy_blocked(&allowed),
8191 "route.list must remain allowed"
8192 );
8193 }
8194
8195 #[tokio::test]
8199 async fn animation_scrub_capture_requires_the_screenshot_tool() {
8200 let cfg = PrivacyConfig {
8201 disabled_tools: HashSet::from(["screenshot".to_string()]),
8202 ..Default::default()
8203 };
8204 let h = handler(cfg);
8205 let r = call(
8206 &h,
8207 "animation",
8208 serde_json::json!({"action": "scrub", "selector": "#toast", "capture": true}),
8209 )
8210 .await;
8211 assert!(
8212 is_privacy_blocked(&r),
8213 "scrub capture must be refused while `screenshot` is disabled, got: {:?}",
8214 r.content
8215 );
8216 assert!(
8217 r.content
8218 .iter()
8219 .any(|c| matches!(c, ContentBlock::Text(t) if t.text.contains("screenshot"))),
8220 "the refusal must name the screenshot tool: {:?}",
8221 r.content
8222 );
8223 let r = call(
8224 &h,
8225 "animation",
8226 serde_json::json!({"action": "scrub", "selector": "#toast"}),
8227 )
8228 .await;
8229 assert!(!is_privacy_blocked(&r), "scrub without capture is allowed");
8230 }
8231
8232 #[tokio::test]
8234 async fn disabling_an_inspect_action_by_its_action_name_is_honored() {
8235 let cfg = PrivacyConfig {
8236 disabled_tools: HashSet::from(["inspect.get_styles".to_string()]),
8237 ..Default::default()
8238 };
8239 let h = handler(cfg);
8240 let r = call(
8241 &h,
8242 "inspect",
8243 serde_json::json!({"action": "get_styles", "ref_id": "e1"}),
8244 )
8245 .await;
8246 assert!(
8247 is_privacy_blocked(&r),
8248 "inspect.get_styles must be blocked, got: {:?}",
8249 r.content
8250 );
8251 let r = call(
8252 &h,
8253 "inspect",
8254 serde_json::json!({"action": "get_bounding_boxes", "ref_ids": ["e1"]}),
8255 )
8256 .await;
8257 assert!(!is_privacy_blocked(&r), "a sibling action stays allowed");
8258 }
8259
8260 #[tokio::test]
8266 async fn full_control_allows_everything_at_dispatch() {
8267 let h = handler(PrivacyConfig::default());
8268 for (tool, args) in [
8269 ("recording", serde_json::json!({"action": "replay"})),
8270 ("route", serde_json::json!({"action": "clear_all"})),
8271 ("eval_js", serde_json::json!({"code": "1"})),
8272 ("fault", serde_json::json!({"action": "list"})),
8273 ] {
8274 let r = call(&h, tool, args.clone()).await;
8275 assert!(
8276 !is_privacy_blocked(&r),
8277 "FullControl must allow {tool} {args}"
8278 );
8279 }
8280 }
8281}
8282
8283#[cfg(test)]
8297mod command_policy_dispatch_tests {
8298 use super::*;
8299 use crate::bridge::WebviewBridge;
8300 use crate::privacy::PrivacyConfig;
8301 use serde_json::json;
8302 use std::collections::{HashMap, HashSet};
8303 use std::sync::Mutex as StdMutex;
8304 use victauri_core::{
8305 AppEvent, CommandRegistry, EventLog, EventRecorder, IpcCall, IpcResult, RecordedEvent,
8306 RecordedSession, WindowState,
8307 };
8308
8309 #[derive(Clone, Default)]
8319 struct RecordingBridge {
8320 scripts: Arc<StdMutex<Vec<String>>>,
8321 pending_evals: Option<crate::PendingCallbacks>,
8322 page_nonce: Arc<StdMutex<Option<String>>>,
8324 eval_answer: Arc<StdMutex<Option<String>>>,
8326 eval_answer_delay: Arc<StdMutex<std::time::Duration>>,
8328 natives: Arc<StdMutex<Vec<String>>>,
8330 probe_silent: Arc<AtomicBool>,
8332 }
8333
8334 fn extract_probe_id(script: &str) -> Option<String> {
8336 let start = script.find("id:\"")? + 4;
8337 script.get(start..start + 36).map(str::to_string)
8338 }
8339
8340 fn extract_wrapper_id(script: &str) -> Option<String> {
8342 let start = script.find("__vic = { id: \"")? + 15;
8343 script.get(start..start + 36).map(str::to_string)
8344 }
8345
8346 impl RecordingBridge {
8347 fn answering(pending_evals: crate::PendingCallbacks) -> Self {
8351 Self {
8352 pending_evals: Some(pending_evals),
8353 ..Self::default()
8354 }
8355 }
8356
8357 fn in_page(pending_evals: crate::PendingCallbacks, nonce: &str) -> Self {
8359 let b = Self::answering(pending_evals);
8360 b.load_page(nonce);
8361 b
8362 }
8363
8364 fn load_page(&self, nonce: &str) {
8366 *self
8367 .page_nonce
8368 .lock()
8369 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(nonce.to_string());
8370 }
8371
8372 fn answer_evals_with(&self, body: &str) {
8373 *self
8374 .eval_answer
8375 .lock()
8376 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(body.to_string());
8377 }
8378
8379 fn answer_evals_after(&self, body: &str, delay: std::time::Duration) {
8382 self.answer_evals_with(body);
8383 *self
8384 .eval_answer_delay
8385 .lock()
8386 .unwrap_or_else(std::sync::PoisonError::into_inner) = delay;
8387 }
8388
8389 fn natives(&self) -> Vec<String> {
8390 self.natives
8391 .lock()
8392 .unwrap_or_else(std::sync::PoisonError::into_inner)
8393 .clone()
8394 }
8395
8396 fn record_native(&self, what: String) -> Result<(), String> {
8397 self.natives
8398 .lock()
8399 .unwrap_or_else(std::sync::PoisonError::into_inner)
8400 .push(what);
8401 Ok(())
8402 }
8403
8404 fn invoked(&self, command: &str) -> bool {
8406 let needle = format!("invoke({}", js_string(command));
8407 self.scripts
8408 .lock()
8409 .unwrap_or_else(std::sync::PoisonError::into_inner)
8410 .iter()
8411 .any(|s| s.contains(&needle))
8412 }
8413 }
8414
8415 impl WebviewBridge for RecordingBridge {
8416 fn eval_webview(&self, _label: Option<&str>, script: &str) -> Result<(), String> {
8417 self.scripts
8418 .lock()
8419 .unwrap_or_else(std::sync::PoisonError::into_inner)
8420 .push(script.to_string());
8421 let mut delay = std::time::Duration::ZERO;
8427 let answer = if script.contains("probe_ok") {
8428 if self.probe_silent.load(Ordering::SeqCst) {
8429 return Ok(());
8430 }
8431 extract_probe_id(script).map(|id| {
8432 let nonce = self
8433 .page_nonce
8434 .lock()
8435 .unwrap_or_else(std::sync::PoisonError::into_inner)
8436 .clone();
8437 let body = nonce.map_or_else(
8438 || "\"probe_ok\"".to_string(),
8439 |n| format!("\"probe_ok:{n}\""),
8440 );
8441 (id, body)
8442 })
8443 } else {
8444 let body = self
8445 .eval_answer
8446 .lock()
8447 .unwrap_or_else(std::sync::PoisonError::into_inner)
8448 .clone();
8449 delay = *self
8450 .eval_answer_delay
8451 .lock()
8452 .unwrap_or_else(std::sync::PoisonError::into_inner);
8453 extract_wrapper_id(script).zip(body)
8454 };
8455 if let (Some(pending), Some((id, body))) = (&self.pending_evals, answer) {
8456 let pending = pending.clone();
8457 std::thread::spawn(move || {
8458 std::thread::sleep(delay);
8459 let mut map = pending.blocking_lock();
8460 if let Some(tx) = map.remove(&id) {
8461 let _ = tx.send(body);
8462 }
8463 });
8464 }
8465 Ok(())
8468 }
8469 fn native_click(&self, _l: Option<&str>, x: f64, y: f64) -> Result<(), String> {
8470 self.record_native(format!("click {x},{y}"))
8471 }
8472 fn native_type_text(&self, _l: Option<&str>, text: &str) -> Result<(), String> {
8473 self.record_native(format!("type {text}"))
8474 }
8475 fn native_key(&self, _l: Option<&str>, key: &str) -> Result<(), String> {
8476 self.record_native(format!("key {key}"))
8477 }
8478 fn get_window_states(&self, _l: Option<&str>) -> Vec<WindowState> {
8479 Vec::new()
8480 }
8481 fn list_window_labels(&self) -> Vec<String> {
8482 Vec::new()
8483 }
8484 fn get_native_handle(&self, _l: Option<&str>) -> Result<isize, String> {
8485 Err("no handle".to_string())
8486 }
8487 fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
8488 Err("no window".to_string())
8489 }
8490 fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
8491 Ok(())
8492 }
8493 fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
8494 Ok(())
8495 }
8496 fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
8497 Ok(())
8498 }
8499 }
8500
8501 fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
8502 Arc::new(VictauriState {
8503 event_log: EventLog::new(1000),
8504 registry: CommandRegistry::new(),
8505 port: std::sync::atomic::AtomicU16::new(0),
8506 pending_evals: Arc::new(Mutex::new(HashMap::new())),
8507 recorder: EventRecorder::new(1000),
8508 privacy,
8509 eval_timeout: std::time::Duration::from_millis(100),
8510 shutdown_tx: tokio::sync::watch::channel(false).0,
8511 started_at: std::time::Instant::now(),
8512 tool_invocations: std::sync::atomic::AtomicU64::new(0),
8513 allow_file_navigation: false,
8514 command_timings: crate::introspection::CommandTimings::new(),
8515 fault_registry: crate::introspection::FaultRegistry::new(),
8516 contract_store: crate::introspection::ContractStore::new(),
8517 startup_timeline: crate::introspection::StartupTimeline::new(),
8518 event_bus: crate::introspection::EventBusMonitor::default(),
8519 task_tracker: crate::introspection::TaskTracker::new(),
8520 bridge_ready: std::sync::atomic::AtomicBool::new(true),
8521 bridge_notify: tokio::sync::Notify::new(),
8522 db_search_paths: Vec::new(),
8523 screencast: Arc::new(crate::screencast::Screencast::default()),
8524 probes: crate::introspection::AppStateProbes::default(),
8525 drain_watermarks: crate::introspection::DrainWatermarks::default(),
8526 page_loads: crate::introspection::PageLoads::default(),
8527 server_runtime: std::sync::OnceLock::new(),
8528 backend_logs: std::sync::Arc::new(crate::backend_logs::LogBuffer::default()),
8529 })
8530 }
8531
8532 fn blocking(cmds: &[&str]) -> PrivacyConfig {
8536 PrivacyConfig {
8537 command_blocklist: cmds.iter().map(|s| (*s).to_string()).collect(),
8538 ..Default::default()
8539 }
8540 }
8541
8542 fn ipc_event(command: &str) -> AppEvent {
8543 AppEvent::Ipc(IpcCall::new(
8544 format!("c-{command}"),
8545 command.to_string(),
8546 chrono::Utc::now(),
8547 IpcResult::Ok(json!(true)),
8548 Some(1),
8549 0,
8550 "main".to_string(),
8551 ))
8552 }
8553
8554 fn result_text(r: &CallToolResult) -> String {
8555 r.content
8556 .iter()
8557 .filter_map(|c| match c {
8558 ContentBlock::Text(t) => Some(t.text.clone()),
8559 _ => None,
8560 })
8561 .collect::<Vec<_>>()
8562 .join("\n")
8563 }
8564
8565 async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
8566 match h.execute_tool(tool, args).await {
8567 Ok(r) => r,
8568 Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
8569 }
8570 }
8571
8572 #[tokio::test]
8574 async fn event_bus_caps_output_to_limit() {
8575 use crate::introspection::CapturedTauriEvent;
8578 let state = state_with(PrivacyConfig::default());
8579 for i in 0..150 {
8580 state.event_bus.push(CapturedTauriEvent {
8581 name: format!("evt-{i}"),
8582 payload: "{}".to_string(),
8583 timestamp: chrono::Utc::now().to_rfc3339(),
8584 });
8585 }
8586 let h = VictauriMcpHandler::new(state, Arc::new(RecordingBridge::default()));
8587
8588 let r = call(&h, "introspect", json!({"action": "event_bus"})).await;
8590 let v: serde_json::Value = serde_json::from_str(&result_text(&r)).unwrap();
8591 assert_eq!(
8592 v["tauri_events"]["count"], 150,
8593 "true total must be reported"
8594 );
8595 assert_eq!(v["tauri_events"]["returned"], 100, "default cap is 100");
8596 assert_eq!(v["tauri_events"]["truncated"], true);
8597 assert_eq!(v["tauri_events"]["events"].as_array().unwrap().len(), 100);
8598
8599 let r = call(
8601 &h,
8602 "introspect",
8603 json!({"action": "event_bus", "args": {"limit": 10}}),
8604 )
8605 .await;
8606 let v: serde_json::Value = serde_json::from_str(&result_text(&r)).unwrap();
8607 assert_eq!(v["tauri_events"]["returned"], 10);
8608 assert_eq!(v["tauri_events"]["events"].as_array().unwrap().len(), 10);
8609 }
8610
8611 #[tokio::test]
8614 async fn replay_never_invokes_a_blocklisted_command() {
8615 let bridge = RecordingBridge::default();
8616 let state = state_with(blocking(&["delete_account"]));
8617 state.recorder.start("s1".to_string()).unwrap();
8618 state.recorder.record_event(ipc_event("delete_account"));
8619 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8620
8621 let r = call(&h, "recording", json!({"action": "replay"})).await;
8622
8623 assert!(
8624 !bridge.invoked("delete_account"),
8625 "SIDE-EFFECT LEAK: replay handed a blocklisted command's invoke to the bridge (audit #30/#31)"
8626 );
8627 assert!(
8628 result_text(&r).contains("blocked"),
8629 "replay should report the command as blocked, got: {}",
8630 result_text(&r)
8631 );
8632 }
8633
8634 #[tokio::test]
8635 async fn replay_does_invoke_an_allowed_command() {
8636 let state = state_with(PrivacyConfig::default());
8639 let bridge = RecordingBridge::answering(state.pending_evals.clone());
8640 state.recorder.start("s1".to_string()).unwrap();
8641 state.recorder.record_event(ipc_event("greet"));
8642 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8643
8644 let _ = call(&h, "recording", json!({"action": "replay"})).await;
8645
8646 assert!(
8647 bridge.invoked("greet"),
8648 "positive control failed: an ALLOWED command was not invoked, so the negative test proves nothing"
8649 );
8650 }
8651
8652 #[tokio::test]
8653 async fn imported_session_cannot_invoke_a_blocklisted_command() {
8654 let bridge = RecordingBridge::default();
8657 let state = state_with(blocking(&["wipe_database"]));
8658 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8659
8660 let session = RecordedSession::new(
8661 "poisoned".to_string(),
8662 chrono::Utc::now(),
8663 vec![RecordedEvent::new(
8664 0,
8665 chrono::Utc::now(),
8666 ipc_event("wipe_database"),
8667 )],
8668 Vec::new(),
8669 );
8670 let session_json = serde_json::to_string(&session).unwrap();
8671
8672 let imp = call(
8673 &h,
8674 "recording",
8675 json!({"action": "import", "session_json": session_json}),
8676 )
8677 .await;
8678 assert_ne!(
8679 imp.is_error,
8680 Some(true),
8681 "import itself should succeed: {}",
8682 result_text(&imp)
8683 );
8684
8685 let r = call(&h, "recording", json!({"action": "replay"})).await;
8686 assert!(
8687 !bridge.invoked("wipe_database"),
8688 "SIDE-EFFECT LEAK: an imported session replayed a blocklisted command (audit #31)"
8689 );
8690 assert!(result_text(&r).contains("blocked"));
8691 }
8692
8693 #[tokio::test]
8694 async fn replay_skips_calls_it_cannot_reproduce_faithfully() {
8695 let state = state_with(PrivacyConfig::default());
8699 let bridge = RecordingBridge::answering(state.pending_evals.clone());
8700 state.recorder.start("s".to_string()).unwrap();
8701 let AppEvent::Ipc(mut with_args) = ipc_event("delete_todo") else {
8702 unreachable!()
8703 };
8704 with_args.arg_size_bytes = 8;
8705 state.recorder.record_event(AppEvent::Ipc(with_args));
8706 let AppEvent::Ipc(mut failed) = ipc_event("flaky") else {
8707 unreachable!()
8708 };
8709 failed.result = IpcResult::Err("boom".to_string());
8710 state.recorder.record_event(AppEvent::Ipc(failed));
8711 state.recorder.record_event(ipc_event("get_counter"));
8712 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
8713
8714 let r = call(&h, "recording", json!({"action": "replay"})).await;
8715 let text = result_text(&r);
8716 assert!(
8717 !bridge.invoked("delete_todo"),
8718 "a call with args must not be replayed"
8719 );
8720 assert!(
8721 !bridge.invoked("flaky"),
8722 "a failed call must not be replayed"
8723 );
8724 assert!(
8725 bridge.invoked("get_counter"),
8726 "a successful no-arg call is replayed"
8727 );
8728 let v: serde_json::Value = serde_json::from_str(&text).unwrap();
8729 assert_eq!(v["skipped"], 2, "{text}");
8730 assert_eq!(v["replayed"], 1, "{text}");
8731 }
8732
8733 fn slow_eval_state() -> Arc<VictauriState> {
8736 let Ok(mut s) = Arc::try_unwrap(state_with(PrivacyConfig::default())) else {
8737 unreachable!("fresh Arc has one owner")
8738 };
8739 s.eval_timeout = std::time::Duration::from_secs(20);
8740 Arc::new(s)
8741 }
8742
8743 #[tokio::test]
8744 async fn eval_reports_a_window_closed_mid_call_instead_of_timing_out() {
8745 let state = slow_eval_state();
8749 let bridge = RecordingBridge::answering(state.pending_evals.clone());
8750 let h = VictauriMcpHandler::new(state, Arc::new(bridge));
8751 let started = std::time::Instant::now();
8752 let r = call(
8753 &h,
8754 "eval_js",
8755 json!({"code": "await new Promise(() => {})", "webview_label": "popup"}),
8756 )
8757 .await;
8758 let text = result_text(&r);
8759 assert_eq!(r.is_error, Some(true), "{text}");
8760 assert!(
8761 text.contains("was closed while the call was in flight"),
8762 "{text}"
8763 );
8764 assert!(
8765 started.elapsed() < std::time::Duration::from_secs(5),
8766 "must not wait out the 20s eval timeout (took {:?})",
8767 started.elapsed()
8768 );
8769 }
8770
8771 struct WedgedListingBridge(RecordingBridge);
8774
8775 impl WebviewBridge for WedgedListingBridge {
8776 fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
8777 self.0.eval_webview(label, script)
8778 }
8779 fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
8780 self.0.get_window_states(label)
8781 }
8782 fn list_window_labels(&self) -> Vec<String> {
8783 Vec::new()
8784 }
8785 fn try_list_window_labels(&self) -> Result<Vec<String>, String> {
8786 Err("list_window_labels did not complete on the main thread: timed out".to_string())
8787 }
8788 fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
8789 self.0.get_native_handle(label)
8790 }
8791 fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
8792 self.0.manage_window(label, action)
8793 }
8794 fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
8795 self.0.resize_window(label, w, h)
8796 }
8797 fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
8798 self.0.move_window(label, x, y)
8799 }
8800 fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
8801 self.0.set_window_title(label, title)
8802 }
8803 }
8804
8805 #[tokio::test]
8806 async fn page_originated_evals_cannot_starve_agent_evals() {
8807 let state = state_with(PrivacyConfig::default());
8810 let mut held = Vec::new();
8811 for i in 0..crate::tools::MAX_PAGE_PENDING_EVALS {
8812 let (tx, rx) = tokio::sync::oneshot::channel();
8813 let label = format!("w{}", i % 5);
8815 let slot = crate::tools::reserve_page_eval(&state, &label, tx)
8816 .await
8817 .unwrap();
8818 held.push((slot, rx));
8819 }
8820 let (tx, _rx) = tokio::sync::oneshot::channel();
8821 let over = crate::tools::reserve_page_eval(&state, "w9", tx).await;
8822 assert!(over.err().unwrap().contains("page-originated"));
8823 let bridge = RecordingBridge::answering(state.pending_evals.clone());
8825 let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
8826 let (tx, _rx2) = tokio::sync::oneshot::channel();
8827 assert!(h.reserve_pending("agent-1", tx).await.is_ok());
8828 drop(held);
8829 }
8830
8831 #[tokio::test]
8832 async fn one_window_cannot_starve_another_windows_page_evals() {
8833 let state = state_with(PrivacyConfig::default());
8834 let mut held = Vec::new();
8835 let mut refused = None;
8836 for _ in 0..=crate::tools::MAX_PAGE_PENDING_EVALS_PER_WINDOW {
8838 let (tx, rx) = tokio::sync::oneshot::channel();
8839 match crate::tools::reserve_page_eval(&state, "a", tx).await {
8840 Ok(slot) => held.push((slot, rx)),
8841 Err(e) => refused = Some(e),
8842 }
8843 }
8844 assert_eq!(held.len(), crate::tools::MAX_PAGE_PENDING_EVALS_PER_WINDOW);
8845 assert!(refused.unwrap().contains("window 'a'"));
8846 let (tx, _rx) = tokio::sync::oneshot::channel();
8847 let other = crate::tools::reserve_page_eval(&state, "a:b", tx).await;
8848 assert!(
8849 other.is_ok(),
8850 "window 'a:b' was starved by window 'a': {:?}",
8851 other.as_ref().err()
8852 );
8853 drop(held);
8855 drop(other);
8856 assert!(state.pending_evals.lock().await.is_empty());
8857 }
8858
8859 #[tokio::test]
8860 async fn a_busy_ui_is_not_mistaken_for_a_closed_window() {
8861 let Ok(mut s) = Arc::try_unwrap(state_with(PrivacyConfig::default())) else {
8865 unreachable!("fresh Arc has one owner")
8866 };
8867 s.eval_timeout = std::time::Duration::from_millis(2500);
8868 let state = Arc::new(s);
8869 let bridge = WedgedListingBridge(RecordingBridge::answering(state.pending_evals.clone()));
8870 let h = VictauriMcpHandler::new(state, Arc::new(bridge));
8871 let r = call(
8872 &h,
8873 "eval_js",
8874 json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8875 )
8876 .await;
8877 let text = result_text(&r);
8878 assert!(
8879 !text.contains("was closed"),
8880 "false 'window closed': {text}"
8881 );
8882 assert!(text.contains("timed out"), "{text}");
8883 }
8884
8885 struct MainWindowBridge(RecordingBridge);
8887
8888 impl WebviewBridge for MainWindowBridge {
8889 fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
8890 self.0.eval_webview(label, script)
8891 }
8892 fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
8893 self.0.get_window_states(label)
8894 }
8895 fn list_window_labels(&self) -> Vec<String> {
8896 vec!["main".to_string()]
8897 }
8898 fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
8899 self.0.get_native_handle(label)
8900 }
8901 fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
8902 self.0.manage_window(label, action)
8903 }
8904 fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
8905 self.0.resize_window(label, w, h)
8906 }
8907 fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
8908 self.0.move_window(label, x, y)
8909 }
8910 fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
8911 self.0.set_window_title(label, title)
8912 }
8913 }
8914
8915 fn eval_state_with_timeout(ms: u64) -> Arc<VictauriState> {
8916 let Ok(mut s) = Arc::try_unwrap(state_with(PrivacyConfig::default())) else {
8917 unreachable!("fresh Arc has one owner")
8918 };
8919 s.eval_timeout = std::time::Duration::from_millis(ms);
8920 Arc::new(s)
8921 }
8922
8923 #[tokio::test]
8924 async fn eval_fails_fast_when_its_page_reloads() {
8925 let state = eval_state_with_timeout(20_000);
8926 let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8927 let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
8928 let reloader = state.clone();
8929 tokio::spawn(async move {
8930 tokio::time::sleep(std::time::Duration::from_millis(600)).await;
8931 inner.load_page("page-2"); ready_signal(&reloader, "main", "page-2");
8933 });
8934 let started = std::time::Instant::now();
8935 let r = call(
8936 &h,
8937 "eval_js",
8938 json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8939 )
8940 .await;
8941 let text = result_text(&r);
8942 assert!(text.contains("loaded a new page"), "{text}");
8943 assert!(
8944 started.elapsed() < std::time::Duration::from_secs(3),
8945 "must not wait out the 20s timeout (took {:?})",
8946 started.elapsed()
8947 );
8948 assert!(
8949 state.pending_evals.lock().await.is_empty(),
8950 "pending entry removed"
8951 );
8952 }
8953
8954 #[tokio::test]
8955 async fn a_late_ready_signal_from_the_same_page_is_not_a_reload() {
8956 let state = eval_state_with_timeout(1_500);
8959 let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
8960 let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner)));
8961 let late = state.clone();
8962 tokio::spawn(async move {
8963 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8964 ready_signal(&late, "main", "page-1");
8965 });
8966 let r = call(
8967 &h,
8968 "eval_js",
8969 json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
8970 )
8971 .await;
8972 let text = result_text(&r);
8973 assert!(!text.contains("loaded a new page"), "false reload: {text}");
8974 assert!(text.contains("timed out"), "{text}");
8975 }
8976
8977 #[tokio::test]
8978 async fn invoke_command_honors_timeout_ms_and_skips_aborted_timings() {
8979 let state = eval_state_with_timeout(20_000);
8980 let bridge = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone()));
8981 let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
8982 let started = std::time::Instant::now();
8983 let r = call(
8984 &h,
8985 "invoke_command",
8986 json!({"command": "slow_thing", "webview_label": "main", "timeout_ms": 400}),
8987 )
8988 .await;
8989 let text = result_text(&r);
8990 assert!(
8991 started.elapsed() < std::time::Duration::from_secs(3),
8992 "timeout_ms ignored"
8993 );
8994 assert!(text.contains("timed out after 400ms"), "{text}");
8995 assert!(
8996 text.contains("timeout_ms"),
8997 "the error must point at timeout_ms: {text}"
8998 );
8999 assert!(
9000 state.command_timings.stats_for("slow_thing").is_none(),
9001 "a timed-out call is not a command duration"
9002 );
9003 }
9004
9005 #[tokio::test]
9006 async fn eval_reports_app_shutdown_instead_of_timing_out() {
9007 let state = slow_eval_state();
9008 let bridge = RecordingBridge::answering(state.pending_evals.clone());
9009 let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9010 let shutdown = state.clone();
9011 tokio::spawn(async move {
9012 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
9013 let _ = shutdown.shutdown_tx.send(true);
9014 });
9015 let r = call(
9016 &h,
9017 "eval_js",
9018 json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
9019 )
9020 .await;
9021 let text = result_text(&r);
9022 assert_eq!(r.is_error, Some(true), "{text}");
9023 assert!(text.contains("shutting down"), "{text}");
9024 assert!(
9025 state.pending_evals.lock().await.is_empty(),
9026 "pending entry removed"
9027 );
9028 }
9029
9030 fn ready_signal(state: &VictauriState, label: &str, nonce: &str) {
9032 state.page_loads.record_load(label, Some(nonce));
9033 }
9034
9035 struct SlowUiBridge(MainWindowBridge, std::time::Duration);
9037
9038 impl WebviewBridge for SlowUiBridge {
9039 fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
9040 std::thread::sleep(self.1);
9041 self.0.eval_webview(label, script)
9042 }
9043 fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9044 self.0.get_window_states(label)
9045 }
9046 fn list_window_labels(&self) -> Vec<String> {
9047 self.0.list_window_labels()
9048 }
9049 fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
9050 self.0.get_native_handle(label)
9051 }
9052 fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
9053 self.0.manage_window(label, action)
9054 }
9055 fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
9056 self.0.resize_window(label, w, h)
9057 }
9058 fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
9059 self.0.move_window(label, x, y)
9060 }
9061 fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
9062 self.0.set_window_title(label, title)
9063 }
9064 }
9065
9066 #[tokio::test]
9072 async fn an_eval_timeout_covers_the_whole_call_on_a_busy_ui_thread() {
9073 let state = eval_state_with_timeout(3_000);
9074 let inner = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone()));
9075 let bridge = SlowUiBridge(inner, std::time::Duration::from_millis(600));
9076 let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9077 let (text, took) = hanging_eval(&h).await;
9078 assert!(text.contains("timed out"), "{text}");
9079 assert!(
9080 took < std::time::Duration::from_millis(4_000),
9081 "the wait restarted after the slow round trips: {took:?} for a 3 s eval"
9082 );
9083 assert!(
9084 state.pending_evals.lock().await.is_empty(),
9085 "pending entry removed"
9086 );
9087 }
9088
9089 #[tokio::test]
9094 async fn a_short_timeout_still_sends_when_the_probe_is_slower_than_it() {
9095 let state = eval_state_with_timeout(100);
9096 let recording = RecordingBridge::answering(state.pending_evals.clone());
9097 let bridge = SlowUiBridge(
9098 MainWindowBridge(recording.clone()),
9099 std::time::Duration::from_millis(300),
9100 );
9101 let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9102 let r = call(
9103 &h,
9104 "invoke_command",
9105 json!({"command": "greet", "webview_label": "main"}),
9106 )
9107 .await;
9108 let text = result_text(&r);
9109 assert!(
9110 recording.invoked("greet"),
9111 "the code was never sent: {text}"
9112 );
9113 assert!(
9114 !text.contains("not responding"),
9115 "false dead-page diagnosis: {text}"
9116 );
9117 }
9118
9119 #[tokio::test]
9127 async fn code_that_reached_the_page_gets_its_full_result_window() {
9128 let state = eval_state_with_timeout(500);
9129 let recording = RecordingBridge::answering(state.pending_evals.clone());
9130 recording.answer_evals_after(
9131 r#"{"__victauri_ok":2,"__victauri_type":"value"}"#,
9132 std::time::Duration::from_millis(900),
9133 );
9134 let bridge = SlowUiBridge(
9135 MainWindowBridge(recording),
9136 std::time::Duration::from_millis(700),
9137 );
9138 let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9139 let outcome = h
9140 .eval_outcome("1 + 1", Some("main"), std::time::Duration::from_millis(500))
9141 .await;
9142 assert!(
9143 outcome.is_ok(),
9144 "the answer came inside the result window but was reported as {:?}",
9145 outcome.err().map(|f| f.message)
9146 );
9147 }
9148
9149 #[tokio::test]
9154 async fn a_page_that_never_signals_ready_gets_the_dead_page_diagnosis() {
9155 let state = eval_state_with_timeout(1_000);
9156 state
9157 .bridge_ready
9158 .store(false, std::sync::atomic::Ordering::SeqCst);
9159 let h = VictauriMcpHandler::new(
9161 state,
9162 Arc::new(MainWindowBridge(RecordingBridge::default())),
9163 );
9164 let started = std::time::Instant::now();
9165 let failure = h
9166 .eval_outcome("1 + 1", Some("main"), std::time::Duration::from_secs(1))
9167 .await
9168 .expect_err("nothing answers");
9169 assert_eq!(
9170 failure.kind,
9171 EvalFailureKind::NotSent,
9172 "{}",
9173 failure.message
9174 );
9175 assert!(
9176 failure.message.contains("bridge not responding"),
9177 "wrong diagnosis: {}",
9178 failure.message
9179 );
9180 assert!(
9181 started.elapsed() < std::time::Duration::from_millis(4_000),
9182 "{:?}",
9183 started.elapsed()
9184 );
9185 }
9186
9187 #[tokio::test]
9191 async fn every_eval_round_trip_runs_under_the_calls_budget() {
9192 struct BudgetSpy(MainWindowBridge, Arc<StdMutex<Vec<std::time::Duration>>>);
9193 impl WebviewBridge for BudgetSpy {
9194 fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
9195 self.1
9196 .lock()
9197 .unwrap_or_else(std::sync::PoisonError::into_inner)
9198 .push(crate::bridge::start_timeout());
9199 self.0.eval_webview(label, script)
9200 }
9201 fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9202 self.0.get_window_states(label)
9203 }
9204 fn list_window_labels(&self) -> Vec<String> {
9205 self.0.list_window_labels()
9206 }
9207 fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
9208 self.0.get_native_handle(label)
9209 }
9210 fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
9211 self.0.manage_window(label, action)
9212 }
9213 fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
9214 self.0.resize_window(label, w, h)
9215 }
9216 fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
9217 self.0.move_window(label, x, y)
9218 }
9219 fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
9220 self.0.set_window_title(label, title)
9221 }
9222 }
9223 let state = eval_state_with_timeout(300);
9224 let recording = RecordingBridge::answering(state.pending_evals.clone());
9225 recording.answer_evals_with(r#"{"__victauri_ok":2,"__victauri_type":"value"}"#);
9226 let seen = Arc::new(StdMutex::new(Vec::new()));
9227 let h = VictauriMcpHandler::new(
9228 state,
9229 Arc::new(BudgetSpy(MainWindowBridge(recording), Arc::clone(&seen))),
9230 );
9231 h.eval_outcome("1 + 1", Some("main"), std::time::Duration::from_millis(300))
9232 .await
9233 .expect("healthy page answers");
9234 let seen = seen
9235 .lock()
9236 .unwrap_or_else(std::sync::PoisonError::into_inner)
9237 .clone();
9238 assert!(
9239 seen.len() >= 3,
9240 "probe, injection and parse check: {seen:?}"
9241 );
9242 assert!(
9243 seen.iter().all(|b| *b <= MIN_SEND_WINDOW && !b.is_zero()),
9244 "a round trip ran outside the call's budget: {seen:?}"
9245 );
9246 }
9247
9248 #[tokio::test]
9251 async fn an_eval_out_of_time_before_sending_reports_not_sent() {
9252 let state = eval_state_with_timeout(300);
9253 let inner = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone()));
9254 let bridge = SlowUiBridge(inner, std::time::Duration::from_millis(2_700));
9256 let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9257 let failure = h
9258 .eval_outcome("1 + 1", Some("main"), std::time::Duration::from_millis(300))
9259 .await
9260 .expect_err("the budget ran out during the probe");
9261 assert_eq!(
9262 failure.kind,
9263 EvalFailureKind::NotSent,
9264 "{}",
9265 failure.message
9266 );
9267 }
9268
9269 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
9275 async fn a_page_eval_queued_on_a_pinned_app_runtime_cannot_hang_agent_evals() {
9276 let state = eval_state_with_timeout(1_000);
9277 let recording = RecordingBridge::answering(state.pending_evals.clone());
9278 recording.answer_evals_with(r#"{"__victauri_ok":2,"__victauri_type":"value"}"#);
9279 let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(recording)));
9280 let app_rt = tokio::runtime::Builder::new_multi_thread()
9282 .worker_threads(1)
9283 .enable_all()
9284 .build()
9285 .unwrap();
9286 let guard = state.pending_evals.lock().await;
9288 let page_state = state.clone();
9289 app_rt.spawn(async move {
9290 let (tx, _rx) = tokio::sync::oneshot::channel();
9291 let _slot = crate::tools::reserve_page_eval(&page_state, "main", tx).await;
9292 });
9293 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9294 app_rt.spawn(async { std::thread::sleep(std::time::Duration::from_secs(6)) });
9296 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9297 drop(guard);
9298
9299 let started = std::time::Instant::now();
9300 let out = tokio::time::timeout(
9301 std::time::Duration::from_secs(12),
9302 h.eval_outcome("1 + 1", Some("main"), std::time::Duration::from_secs(1)),
9303 )
9304 .await;
9305 let took = started.elapsed();
9306 app_rt.shutdown_background();
9307 assert!(
9309 took < std::time::Duration::from_millis(4_500),
9310 "a 1 s agent eval hung {took:?} behind a page eval queued on the app's pinned \
9311 runtime (outcome: {:?})",
9312 out.map(|r| r.map_err(|f| f.message))
9313 );
9314 }
9315
9316 #[tokio::test]
9324 async fn a_page_side_wait_for_answer_is_not_cut_off_by_slow_sending() {
9325 let state = eval_state_with_timeout(30_000);
9326 let recording = RecordingBridge::answering(state.pending_evals.clone());
9327 recording.answer_evals_after(
9328 r#"{"__victauri_ok":{"ok":true,"elapsed_ms":9900},"__victauri_type":"value"}"#,
9329 std::time::Duration::from_millis(9_900),
9330 );
9331 let bridge = SlowUiBridge(
9332 MainWindowBridge(recording),
9333 std::time::Duration::from_millis(6_500),
9334 );
9335 let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9336 let r = call(
9337 &h,
9338 "wait_for",
9339 json!({"condition": "selector", "value": "#x", "timeout_ms": 10_000,
9340 "webview_label": "main"}),
9341 )
9342 .await;
9343 let text = result_text(&r);
9344 assert!(
9345 !text.contains("timed out"),
9346 "the page's own wait_for answer was cut off by the send slack: {text}"
9347 );
9348 assert!(text.contains("\"ok\":true"), "{text}");
9349 }
9350
9351 #[tokio::test]
9358 async fn an_injection_abandoned_for_lack_of_budget_gets_the_busy_ui_diagnosis() {
9359 struct BudgetedSlowUi(MainWindowBridge, std::time::Duration);
9360 impl WebviewBridge for BudgetedSlowUi {
9361 fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
9362 let budget = crate::bridge::start_timeout();
9363 if self.1 > budget {
9364 std::thread::sleep(budget);
9365 return Err(
9366 "eval_webview did not complete on the main thread: timed out \
9367 before it started (it will not run)"
9368 .to_string(),
9369 );
9370 }
9371 std::thread::sleep(self.1);
9372 self.0.eval_webview(label, script)
9373 }
9374 fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9375 self.0.get_window_states(label)
9376 }
9377 fn list_window_labels(&self) -> Vec<String> {
9378 self.0.list_window_labels()
9379 }
9380 fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
9381 self.0.get_native_handle(label)
9382 }
9383 fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
9384 self.0.manage_window(label, action)
9385 }
9386 fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
9387 self.0.resize_window(label, w, h)
9388 }
9389 fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
9390 self.0.move_window(label, x, y)
9391 }
9392 fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
9393 self.0.set_window_title(label, title)
9394 }
9395 }
9396 for latency_ms in [1_300, 2_700] {
9398 let state = eval_state_with_timeout(1_000);
9399 let recording = RecordingBridge::answering(state.pending_evals.clone());
9400 let bridge = BudgetedSlowUi(
9401 MainWindowBridge(recording),
9402 std::time::Duration::from_millis(latency_ms),
9403 );
9404 let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9405 let failure = h
9406 .eval_outcome(
9407 "1 + 1",
9408 Some("main"),
9409 std::time::Duration::from_millis(1_000),
9410 )
9411 .await
9412 .expect_err("the delivery could not start in time");
9413 assert_eq!(
9414 failure.kind,
9415 EvalFailureKind::NotSent,
9416 "{}",
9417 failure.message
9418 );
9419 assert!(
9420 failure.message.contains("UI thread is busy")
9421 && failure.message.contains("longer timeout"),
9422 "{latency_ms} ms: no busy-UI diagnosis / remedy: {}",
9423 failure.message
9424 );
9425 }
9426 }
9427
9428 async fn hanging_eval(h: &VictauriMcpHandler) -> (String, std::time::Duration) {
9429 let started = std::time::Instant::now();
9430 let r = call(
9431 h,
9432 "eval_js",
9433 json!({"code": "await new Promise(() => {})", "webview_label": "main"}),
9434 )
9435 .await;
9436 (result_text(&r), started.elapsed())
9437 }
9438
9439 #[tokio::test]
9440 async fn a_reload_right_after_injection_is_detected() {
9441 let state = eval_state_with_timeout(20_000);
9444 let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
9445 let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
9446 let reloader = state.clone();
9447 tokio::spawn(async move {
9448 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
9449 inner.load_page("page-2");
9450 ready_signal(&reloader, "main", "page-2");
9451 });
9452 let (text, took) = hanging_eval(&h).await;
9453 assert!(text.contains("loaded a new page"), "{text}");
9454 assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
9455 }
9456
9457 #[tokio::test]
9458 async fn a_same_page_ready_signal_arriving_late_is_not_a_reload() {
9459 let state = eval_state_with_timeout(3_000);
9462 let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
9463 let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner)));
9464 let late = state.clone();
9465 tokio::spawn(async move {
9466 tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
9467 ready_signal(&late, "main", "page-1");
9468 });
9469 let (text, _) = hanging_eval(&h).await;
9470 assert!(!text.contains("loaded a new page"), "false reload: {text}");
9471 assert!(text.contains("timed out"), "{text}");
9472 }
9473
9474 #[tokio::test]
9475 async fn a_forged_ready_signal_does_not_abort_an_eval() {
9476 let state = eval_state_with_timeout(2_500);
9479 let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
9480 let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner)));
9481 let forger = state.clone();
9482 tokio::spawn(async move {
9483 tokio::time::sleep(std::time::Duration::from_millis(600)).await;
9484 ready_signal(&forger, "main", "forged");
9485 });
9486 let (text, _) = hanging_eval(&h).await;
9487 assert!(!text.contains("loaded a new page"), "forged reload: {text}");
9488 assert!(text.contains("timed out"), "{text}");
9489 }
9490
9491 #[tokio::test]
9497 async fn an_unconfirmed_ready_signal_does_not_abort_an_eval() {
9498 let state = eval_state_with_timeout(4_000);
9499 let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
9500 let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
9501 let signal = state.clone();
9502 tokio::spawn(async move {
9503 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
9504 inner.probe_silent.store(true, Ordering::SeqCst); ready_signal(&signal, "main", "forged");
9506 });
9507 let (text, _) = hanging_eval(&h).await;
9508 assert!(
9509 !text.contains("loaded a new page"),
9510 "aborted on no evidence: {text}"
9511 );
9512 assert!(text.contains("timed out"), "{text}");
9513 }
9514
9515 #[tokio::test]
9518 async fn a_timeout_claims_no_parse_error_only_when_the_parse_check_ran() {
9519 let state = eval_state_with_timeout(300);
9520 let h = VictauriMcpHandler::new(
9522 state.clone(),
9523 Arc::new(RecordingBridge::answering(state.pending_evals.clone())),
9524 );
9525 let (text, _) = hanging_eval(&h).await;
9526 assert!(text.contains("timed out"), "{text}");
9527 assert!(!text.contains("NOT a parse"), "unfounded claim: {text}");
9528 assert!(text.contains("could not be ruled out"), "{text}");
9529 let h = VictauriMcpHandler::new(
9531 state.clone(),
9532 Arc::new(RecordingBridge::in_page(state.pending_evals.clone(), "p1")),
9533 );
9534 let (text, _) = hanging_eval(&h).await;
9535 assert!(text.contains("NOT a parse error"), "{text}");
9536 let h = VictauriMcpHandler::new(
9538 state.clone(),
9539 Arc::new(CheckNotDeliveredBridge(RecordingBridge::in_page(
9540 state.pending_evals.clone(),
9541 "p1",
9542 ))),
9543 );
9544 let (text, _) = hanging_eval(&h).await;
9545 assert!(!text.contains("NOT a parse"), "unfounded claim: {text}");
9546 }
9547
9548 struct CheckNotDeliveredBridge(RecordingBridge);
9550
9551 impl WebviewBridge for CheckNotDeliveredBridge {
9552 fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
9553 if script.contains("_evalCheck") {
9554 return Err("window busy".to_string());
9555 }
9556 self.0.eval_webview(label, script)
9557 }
9558 fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9559 self.0.get_window_states(label)
9560 }
9561 fn list_window_labels(&self) -> Vec<String> {
9562 vec!["main".to_string()]
9563 }
9564 fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
9565 self.0.get_native_handle(label)
9566 }
9567 fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
9568 self.0.manage_window(label, action)
9569 }
9570 fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
9571 self.0.resize_window(label, w, h)
9572 }
9573 fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
9574 self.0.move_window(label, x, y)
9575 }
9576 fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
9577 self.0.set_window_title(label, title)
9578 }
9579 }
9580
9581 #[tokio::test]
9584 async fn a_page_without_the_armed_nonce_is_a_reload() {
9585 let state = eval_state_with_timeout(20_000);
9586 let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1");
9587 let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone())));
9588 let reloader = state.clone();
9589 tokio::spawn(async move {
9590 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
9591 *inner
9592 .page_nonce
9593 .lock()
9594 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
9595 ready_signal(&reloader, "main", "whatever");
9596 });
9597 let (text, took) = hanging_eval(&h).await;
9598 assert!(text.contains("loaded a new page"), "{text}");
9599 assert!(took < std::time::Duration::from_secs(5), "took {took:?}");
9600 }
9601
9602 #[tokio::test]
9603 async fn an_eval_started_after_app_exit_fails_fast() {
9604 let state = slow_eval_state();
9607 state.shutdown_tx.send_replace(true);
9608 let bridge = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone()));
9609 let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9610 let (text, took) =
9611 tokio::time::timeout(std::time::Duration::from_secs(10), hanging_eval(&h))
9612 .await
9613 .expect("an eval after app exit must not wait out its 20s timeout");
9614 assert!(text.contains("shutting down"), "{text}");
9615 assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
9616 }
9617
9618 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
9619 async fn a_dropped_eval_call_releases_its_pending_slot() {
9620 let state = eval_state_with_timeout(20_000);
9623 let bridge = RecordingBridge::answering(state.pending_evals.clone());
9624 let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
9625 for _ in 0..3 {
9626 let dropped = tokio::time::timeout(
9627 std::time::Duration::from_millis(300),
9628 h.execute_tool("eval_js", json!({"code": "await new Promise(() => {})"})),
9629 )
9630 .await;
9631 assert!(
9632 dropped.is_err(),
9633 "the call must still be in flight when dropped"
9634 );
9635 }
9636 let generation = state
9639 .recorder
9640 .start_session("slot-release".to_string())
9641 .unwrap();
9642 state.drain_watermarks.reset(0.0, generation);
9643 let bridge: Arc<dyn WebviewBridge> = Arc::new(RecordingBridge::default());
9644 let dropped = tokio::time::timeout(
9645 std::time::Duration::from_millis(300),
9646 drain_window_into_recording(&state, &bridge, "main"),
9647 )
9648 .await;
9649 assert!(
9650 dropped.is_err(),
9651 "the drain must still be in flight when dropped"
9652 );
9653 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9655 assert!(
9656 state.pending_evals.lock().await.is_empty(),
9657 "a dropped call leaked its pending-eval slot"
9658 );
9659 }
9660
9661 struct InjectFailsBridge(RecordingBridge);
9663
9664 impl WebviewBridge for InjectFailsBridge {
9665 fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
9666 self.0.eval_webview(label, script)?;
9667 if script.contains("probe_ok") {
9668 Ok(())
9669 } else {
9670 Err("window not found: main".to_string())
9671 }
9672 }
9673 fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
9674 self.0.get_window_states(label)
9675 }
9676 fn list_window_labels(&self) -> Vec<String> {
9677 vec!["main".to_string()]
9678 }
9679 fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
9680 self.0.get_native_handle(label)
9681 }
9682 fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
9683 self.0.manage_window(label, action)
9684 }
9685 fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> {
9686 self.0.resize_window(label, w, h)
9687 }
9688 fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
9689 self.0.move_window(label, x, y)
9690 }
9691 fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
9692 self.0.set_window_title(label, title)
9693 }
9694 }
9695
9696 #[tokio::test]
9697 async fn invoke_command_records_no_timing_for_calls_that_never_ran() {
9698 let state = eval_state_with_timeout(1_000);
9701 let fillers: Vec<_> = (0..MAX_PENDING_EVALS)
9702 .map(|_| tokio::sync::oneshot::channel::<String>())
9703 .collect();
9704 {
9705 let mut p = state.pending_evals.lock().await;
9706 for (i, (tx, _)) in fillers.into_iter().enumerate() {
9707 p.insert(format!("filler-{i}"), tx);
9708 }
9709 }
9710 let answering = RecordingBridge::answering(state.pending_evals.clone());
9711 let h = VictauriMcpHandler::new(state.clone(), Arc::new(answering.clone()));
9712 let r = call(&h, "invoke_command", json!({"command": "saturated"})).await;
9713 assert!(
9714 result_text(&r).contains("too many concurrent"),
9715 "{}",
9716 result_text(&r)
9717 );
9718 state.pending_evals.lock().await.clear();
9719
9720 let dead = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
9721 let r = call(&dead, "invoke_command", json!({"command": "dead_bridge"})).await;
9722 assert!(
9723 result_text(&r).contains("bridge not responding"),
9724 "{}",
9725 result_text(&r)
9726 );
9727
9728 let failing = VictauriMcpHandler::new(
9729 state.clone(),
9730 Arc::new(InjectFailsBridge(RecordingBridge::answering(
9731 state.pending_evals.clone(),
9732 ))),
9733 );
9734 let r = call(
9735 &failing,
9736 "invoke_command",
9737 json!({"command": "not_injected"}),
9738 )
9739 .await;
9740 assert!(
9741 result_text(&r).contains("injection failed"),
9742 "{}",
9743 result_text(&r)
9744 );
9745
9746 for cmd in ["saturated", "dead_bridge", "not_injected"] {
9747 assert!(
9748 state.command_timings.stats_for(cmd).is_none(),
9749 "'{cmd}' never ran but was recorded as a command timing"
9750 );
9751 }
9752
9753 answering.answer_evals_with(r#"{"__victauri_not_run":"did not begin executing"}"#);
9755 let r = call(&h, "invoke_command", json!({"command": "never_parsed"})).await;
9756 assert!(
9757 result_text(&r).contains("parse error"),
9758 "{}",
9759 result_text(&r)
9760 );
9761 assert!(state.command_timings.stats_for("never_parsed").is_none());
9762
9763 answering.answer_evals_with(r#"{"__victauri_err":"boom"}"#);
9765 let r = call(&h, "invoke_command", json!({"command": "ran_and_threw"})).await;
9766 assert!(result_text(&r).contains("boom"), "{}", result_text(&r));
9767 assert!(state.command_timings.stats_for("ran_and_threw").is_some());
9768 }
9769
9770 #[tokio::test]
9771 async fn a_trusted_key_press_stops_when_its_element_cannot_be_focused() {
9772 let state = eval_state_with_timeout(2_000);
9775 let bridge = RecordingBridge::answering(state.pending_evals.clone());
9776 bridge.answer_evals_with(r#"{"__victauri_ok":false,"__victauri_type":"value"}"#);
9777 let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9778 let r = call(
9779 &h,
9780 "input",
9781 json!({"action": "press_key", "key": "Enter", "ref_id": "e9", "trusted": true}),
9782 )
9783 .await;
9784 let text = result_text(&r);
9785 assert_eq!(r.is_error, Some(true), "{text}");
9786 assert!(text.contains("not focusable"), "key sent anyway: {text}");
9787 }
9788
9789 #[tokio::test]
9792 async fn route_delay_is_capped() {
9793 let state = eval_state_with_timeout(2_000);
9794 let bridge = RecordingBridge::answering(state.pending_evals.clone());
9795 bridge.answer_evals_with(&ok_envelope(&json!({"ok": true, "id": 1})));
9796 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9797 let added = |b: &RecordingBridge| {
9798 b.scripts
9799 .lock()
9800 .unwrap_or_else(std::sync::PoisonError::into_inner)
9801 .iter()
9802 .filter(|s| s.contains("addRoute("))
9803 .count()
9804 };
9805 let r = call(
9806 &h,
9807 "route",
9808 json!({"action": "add", "pattern": "/api", "behavior": "delay",
9809 "delay_ms": MAX_FAULT_DELAY_MS + 1}),
9810 )
9811 .await;
9812 let text = result_text(&r);
9813 assert_eq!(r.is_error, Some(true), "{text}");
9814 assert!(text.contains("delay_ms"), "{text}");
9815 assert_eq!(added(&bridge), 0, "the rule must not reach the page");
9816 let r = call(
9818 &h,
9819 "route",
9820 json!({"action": "add", "pattern": "/api", "behavior": "delay",
9821 "delay_ms": MAX_FAULT_DELAY_MS}),
9822 )
9823 .await;
9824 assert_ne!(r.is_error, Some(true), "{}", result_text(&r));
9825 assert_eq!(added(&bridge), 1);
9826 }
9827
9828 #[tokio::test]
9831 async fn animation_scrub_never_claims_a_capture_it_did_not_make() {
9832 let state = eval_state_with_timeout(2_000);
9833 let bridge = RecordingBridge::answering(state.pending_evals.clone());
9834 bridge.answer_evals_with(&ok_envelope(
9835 &json!({"prepared": true, "duration": 100, "anim_count": 1, "t": 0}),
9836 ));
9837 let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9838 let r = call(
9839 &h,
9840 "animation",
9841 json!({"action": "scrub", "selector": "#toast", "points": 2, "capture": true}),
9842 )
9843 .await;
9844 let text = result_text(&r);
9845 assert_ne!(
9846 r.is_error,
9847 Some(true),
9848 "the geometry curve is still returned: {text}"
9849 );
9850 let v: serde_json::Value = serde_json::from_str(&text).unwrap();
9851 assert_eq!(v["captured"], false, "{v}");
9852 assert!(v.get("filmstrip").is_none(), "{v}");
9853 assert!(
9854 v["capture_error"]
9855 .as_str()
9856 .is_some_and(|e| e.contains("no handle")),
9857 "the capture failure must be surfaced: {v}"
9858 );
9859 assert_eq!(v["curve"].as_array().map(Vec::len), Some(2), "{v}");
9860 }
9861
9862 #[tokio::test]
9866 async fn log_limit_zero_returns_no_entries() {
9867 let state = eval_state_with_timeout(2_000);
9868 let bridge = RecordingBridge::answering(state.pending_evals.clone());
9869 bridge.answer_evals_with(&ok_envelope(&json!([{"a": 1}, {"a": 2}])));
9870 let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9871 for action in [
9872 "console",
9873 "network",
9874 "ipc",
9875 "navigation",
9876 "dialogs",
9877 "events",
9878 ] {
9879 let r = call(&h, "logs", json!({"action": action, "limit": 0})).await;
9880 let text = result_text(&r);
9881 assert_ne!(r.is_error, Some(true), "{action}: {text}");
9882 let v: serde_json::Value = serde_json::from_str(&text)
9883 .unwrap_or_else(|e| panic!("{action}: not JSON ({e}): {text}"));
9884 assert_eq!(
9885 v,
9886 json!([]),
9887 "logs {action} limit=0 returned entries: {text}"
9888 );
9889 }
9890 let r = call(&h, "route", json!({"action": "matches", "limit": 0})).await;
9891 let text = result_text(&r);
9892 let v: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
9893 assert_eq!(
9894 v,
9895 json!([]),
9896 "route matches limit=0 returned entries: {text}"
9897 );
9898 }
9899
9900 fn ok_envelope(value: &serde_json::Value) -> String {
9901 json!({"__victauri_ok": value, "__victauri_type": "object"}).to_string()
9902 }
9903
9904 #[tokio::test]
9907 async fn trusted_keys_are_sent_only_when_focus_landed_on_the_element() {
9908 let state = eval_state_with_timeout(2_000);
9909 let bridge = RecordingBridge::answering(state.pending_evals.clone());
9910 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9911 let type_args =
9912 json!({"action": "type_text", "ref_id": "e3", "text": "hi", "trusted": true});
9913 let key_args =
9914 json!({"action": "press_key", "key": "Enter", "ref_id": "e3", "trusted": true});
9915
9916 bridge.answer_evals_with(&ok_envelope(&json!({"found": true, "focused": false})));
9917 for args in [&type_args, &key_args] {
9918 let r = call(&h, "input", args.clone()).await;
9919 let text = result_text(&r);
9920 assert_eq!(r.is_error, Some(true), "{args}: {text}");
9921 assert!(text.contains("focus did not land"), "{args}: {text}");
9922 }
9923 bridge.answer_evals_with(&ok_envelope(&json!({"found": false, "focused": false})));
9924 let r = call(&h, "input", type_args.clone()).await;
9925 assert!(
9926 result_text(&r).contains("ref not found"),
9927 "{}",
9928 result_text(&r)
9929 );
9930 assert!(
9931 bridge.natives().is_empty(),
9932 "keys sent: {:?}",
9933 bridge.natives()
9934 );
9935
9936 bridge.answer_evals_with(&ok_envelope(&json!({"found": true, "focused": true})));
9938 for args in [&type_args, &key_args] {
9939 let r = call(&h, "input", args.clone()).await;
9940 assert_ne!(r.is_error, Some(true), "{args}: {}", result_text(&r));
9941 }
9942 assert_eq!(bridge.natives(), vec!["type hi", "key Enter"]);
9943 }
9944
9945 #[tokio::test]
9948 async fn trusted_click_is_refused_unless_the_page_reports_a_clickable_point() {
9949 let state = eval_state_with_timeout(2_000);
9950 let bridge = RecordingBridge::answering(state.pending_evals.clone());
9951 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
9952 let args = json!({"action": "click", "ref_id": "e5", "trusted": true});
9953 for (answer, expect) in [
9954 (
9955 json!({"error": "element is covered at its center point by <div>"}),
9956 "covered",
9957 ),
9958 (json!({"x": -4.0, "y": 10.0}), "unusable click point"),
9959 (json!(null), "ref not found"),
9960 ] {
9961 bridge.answer_evals_with(&ok_envelope(&answer));
9962 let r = call(&h, "interact", args.clone()).await;
9963 let text = result_text(&r);
9964 assert_eq!(r.is_error, Some(true), "{answer}: {text}");
9965 assert!(text.contains(expect), "{answer}: {text}");
9966 }
9967 assert!(
9968 bridge.natives().is_empty(),
9969 "clicked: {:?}",
9970 bridge.natives()
9971 );
9972 bridge.answer_evals_with(&ok_envelope(&json!({"x": 150.0, "y": 226.0})));
9973 let r = call(&h, "interact", args).await;
9974 assert_ne!(r.is_error, Some(true), "{}", result_text(&r));
9975 assert_eq!(bridge.natives(), vec!["click 150,226"]);
9976 }
9977
9978 #[tokio::test]
9979 async fn an_unserializable_result_is_reported_as_code_that_ran() {
9980 let state = eval_state_with_timeout(2_000);
9983 let bridge = RecordingBridge::answering(state.pending_evals.clone());
9984 bridge.answer_evals_with(
9985 r#"{"__victauri_unserializable":"Do not know how to serialize a BigInt"}"#,
9986 );
9987 let h = VictauriMcpHandler::new(state, Arc::new(bridge));
9988 let r = call(&h, "eval_js", json!({"code": "return 1n"})).await;
9989 let text = result_text(&r);
9990 assert_eq!(r.is_error, Some(true), "{text}");
9991 assert!(text.contains("the code ran"), "{text}");
9992 assert!(text.contains("BigInt"), "{text}");
9993 assert!(!text.contains("JavaScript error"), "{text}");
9994 }
9995
9996 #[tokio::test]
9997 async fn trace_stop_never_stops_a_recording_it_did_not_start() {
9998 let state = state_with(PrivacyConfig::default());
9999 let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
10000 let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await;
10001 let traced = state
10002 .recorder
10003 .active_session_id()
10004 .expect("trace started a recording");
10005 let _ = state.recorder.stop();
10007 state.recorder.start("mine".to_string()).unwrap();
10008 let _ = call(&h, "trace", json!({"action": "stop"})).await;
10009 assert_eq!(
10010 state.recorder.active_session_id().as_deref(),
10011 Some("mine"),
10012 "trace stop must not end a recording it did not start (it started {traced})"
10013 );
10014 }
10015
10016 #[tokio::test]
10017 async fn restarting_a_trace_does_not_orphan_its_recording() {
10018 let state = state_with(PrivacyConfig::default());
10019 let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
10020 let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await;
10021 let first = state.recorder.active_session_id().unwrap();
10022 let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await;
10023 let second = state.recorder.active_session_id().unwrap();
10024 assert_ne!(
10025 first, second,
10026 "the first trace's recording was superseded, not orphaned"
10027 );
10028 let _ = call(&h, "trace", json!({"action": "stop"})).await;
10029 assert!(
10030 !state.recorder.is_recording(),
10031 "stop ends the second trace's recording"
10032 );
10033 }
10034
10035 #[tokio::test]
10036 async fn import_refuses_to_discard_an_active_recording() {
10037 let state = state_with(PrivacyConfig::default());
10038 state.recorder.start("live".to_string()).unwrap();
10039 let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default()));
10040 let session = RecordedSession::new(
10041 "other".to_string(),
10042 chrono::Utc::now(),
10043 Vec::new(),
10044 Vec::new(),
10045 );
10046 let r = call(
10047 &h,
10048 "recording",
10049 json!({"action": "import", "session_json": serde_json::to_string(&session).unwrap()}),
10050 )
10051 .await;
10052 assert_eq!(r.is_error, Some(true), "{}", result_text(&r));
10053 assert_eq!(
10054 state.recorder.export().unwrap().id,
10055 "live",
10056 "active recording kept"
10057 );
10058 }
10059
10060 #[tokio::test]
10063 async fn contract_record_never_invokes_a_blocklisted_command() {
10064 let bridge = RecordingBridge::default();
10065 let state = state_with(blocking(&["delete_account"]));
10066 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
10067
10068 let r = call(
10069 &h,
10070 "introspect",
10071 json!({"action": "contract_record", "command": "delete_account", "args": {"confirm": true}}),
10072 )
10073 .await;
10074
10075 assert!(
10076 !bridge.invoked("delete_account"),
10077 "SIDE-EFFECT LEAK: contract_record invoked a blocklisted command (audit #30)"
10078 );
10079 assert_eq!(r.is_error, Some(true));
10080 assert!(
10081 result_text(&r).contains("blocked by privacy configuration"),
10082 "got: {}",
10083 result_text(&r)
10084 );
10085 }
10086
10087 #[tokio::test]
10088 async fn contract_record_does_invoke_an_allowed_command() {
10089 let state = state_with(PrivacyConfig::default());
10090 let bridge = RecordingBridge::answering(state.pending_evals.clone());
10091 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
10092
10093 let _ = call(
10094 &h,
10095 "introspect",
10096 json!({"action": "contract_record", "command": "get_settings"}),
10097 )
10098 .await;
10099
10100 assert!(
10101 bridge.invoked("get_settings"),
10102 "positive control failed: contract_record did not invoke an allowed command"
10103 );
10104 }
10105
10106 #[tokio::test]
10109 async fn contract_record_samples_a_large_non_ascii_response_without_panicking() {
10110 let state = eval_state_with_timeout(2_000);
10111 let bridge = RecordingBridge::answering(state.pending_evals.clone());
10112 let payload = "é".repeat(3000);
10115 bridge.answer_evals_with(
10116 &json!({"__victauri_ok": payload, "__victauri_type": "string"}).to_string(),
10117 );
10118 let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge));
10119 let r = call(
10120 &h,
10121 "introspect",
10122 json!({"action": "contract_record", "command": "get_notes"}),
10123 )
10124 .await;
10125 let text = result_text(&r);
10126 assert_ne!(r.is_error, Some(true), "{text}");
10127 let baseline = state
10128 .contract_store
10129 .all()
10130 .into_iter()
10131 .find(|b| b.command == "get_notes")
10132 .expect("baseline recorded");
10133 assert!(
10134 baseline.sample.ends_with("...(truncated)"),
10135 "{}",
10136 baseline.sample
10137 );
10138 assert!(baseline.sample.len() <= 4096 + "...(truncated)".len());
10139 }
10140
10141 #[tokio::test]
10143 async fn reserve_pending_is_a_hard_ceiling_under_concurrency() {
10144 let state = state_with(PrivacyConfig::default());
10150 {
10151 let mut p = state.pending_evals.lock().await;
10152 for i in 0..(MAX_PENDING_EVALS - 5) {
10153 let (tx, _rx) = tokio::sync::oneshot::channel();
10154 p.insert(format!("pre-{i}"), tx);
10155 }
10156 }
10157 let h = Arc::new(VictauriMcpHandler::new(
10158 state.clone(),
10159 Arc::new(RecordingBridge::default()),
10160 ));
10161 let mut tasks = Vec::new();
10162 for i in 0..50 {
10163 let h = h.clone();
10164 tasks.push(tokio::spawn(async move {
10165 let (tx, _rx) = tokio::sync::oneshot::channel();
10166 let slot = h.reserve_pending(&format!("c-{i}"), tx).await.ok();
10168 (slot, _rx)
10169 }));
10170 }
10171 let mut granted = 0;
10172 let mut keep = Vec::new();
10173 for t in tasks {
10174 let (slot, rx) = t.await.unwrap();
10175 if slot.is_some() {
10176 granted += 1;
10177 }
10178 keep.push((slot, rx)); }
10180 let len = state.pending_evals.lock().await.len();
10181 assert!(
10182 len <= MAX_PENDING_EVALS,
10183 "ceiling breached: {len} > {MAX_PENDING_EVALS}"
10184 );
10185 assert_eq!(
10186 granted, 5,
10187 "exactly the 5 free slots should have been reserved, got {granted}"
10188 );
10189 drop(keep);
10190 }
10191
10192 #[tokio::test]
10193 async fn contract_check_never_reinvokes_a_now_blocklisted_command() {
10194 let bridge = RecordingBridge::default();
10197 let state = state_with(blocking(&["delete_account"]));
10198 state
10199 .contract_store
10200 .record(crate::introspection::ContractBaseline {
10201 command: "delete_account".to_string(),
10202 args: json!({}),
10203 shape: crate::introspection::JsonShape::from_value(&json!(true)),
10204 sample: "true".to_string(),
10205 recorded_at: chrono_now(),
10206 });
10207 let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
10208
10209 let _ = call(&h, "introspect", json!({"action": "contract_check"})).await;
10210
10211 assert!(
10212 !bridge.invoked("delete_account"),
10213 "SIDE-EFFECT LEAK: contract_check re-invoked a now-blocklisted command (audit #30)"
10214 );
10215 }
10216
10217 #[test]
10220 fn resource_reads_are_gated_by_their_mirrored_capability() {
10221 let cfg = PrivacyConfig {
10224 disabled_tools: HashSet::from([
10225 "logs.ipc".to_string(),
10226 "window.list".to_string(),
10227 "get_plugin_info".to_string(),
10228 ]),
10229 ..Default::default()
10230 };
10231 for uri in [
10232 RESOURCE_URI_IPC_LOG,
10233 RESOURCE_URI_WINDOWS,
10234 RESOURCE_URI_STATE,
10235 ] {
10236 let (_, cap) =
10237 resource_required_capability(uri).expect("resource maps to a capability");
10238 assert!(
10239 !cfg.is_tool_enabled(cap),
10240 "disabling capability {cap} must gate resource {uri} (audit B1)"
10241 );
10242 assert!(
10243 !resource_allowed(&cfg, uri),
10244 "disabling capability {cap} must gate resource {uri} (audit B1)"
10245 );
10246 }
10247 let full = PrivacyConfig::default();
10249 for uri in [
10250 RESOURCE_URI_IPC_LOG,
10251 RESOURCE_URI_WINDOWS,
10252 RESOURCE_URI_STATE,
10253 ] {
10254 assert!(full.is_tool_enabled(resource_required_capability(uri).unwrap().1));
10255 assert!(resource_allowed(&full, uri));
10256 }
10257 }
10258
10259 async fn mcp_body(privacy: PrivacyConfig, method: &str, params: serde_json::Value) -> String {
10262 use http_body_util::BodyExt;
10263 use tower::ServiceExt;
10264 let app = build_app(state_with(privacy), Arc::new(RecordingBridge::default()));
10265 let body = json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params});
10266 let req = axum::http::Request::post("/mcp")
10267 .header("host", "127.0.0.1:7373")
10268 .header("content-type", "application/json")
10269 .header("accept", "application/json, text/event-stream")
10270 .header("mcp-protocol-version", "2025-06-18")
10271 .body(axum::body::Body::from(body.to_string()))
10272 .unwrap();
10273 let resp = tokio::time::timeout(std::time::Duration::from_secs(20), app.oneshot(req))
10274 .await
10275 .expect("an MCP request must be answered")
10276 .unwrap();
10277 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
10278 String::from_utf8_lossy(&bytes).into_owned()
10279 }
10280
10281 #[tokio::test]
10286 async fn a_bare_tool_disable_also_blocks_its_resources() {
10287 for (disabled, uri) in [
10288 ("logs", RESOURCE_URI_IPC_LOG),
10289 ("window", RESOURCE_URI_WINDOWS),
10290 ("get_plugin_info", RESOURCE_URI_STATE),
10291 ] {
10292 let cfg = || PrivacyConfig {
10293 disabled_tools: HashSet::from([disabled.to_string()]),
10294 ..Default::default()
10295 };
10296 for method in ["resources/read", "resources/subscribe"] {
10297 let body = mcp_body(cfg(), method, json!({"uri": uri})).await;
10298 assert!(
10299 body.contains("not permitted by the current privacy configuration"),
10300 "disable_tools([{disabled:?}]) must block {method} {uri}: {body}"
10301 );
10302 }
10303 }
10304 let body = mcp_body(
10306 PrivacyConfig::default(),
10307 "resources/read",
10308 json!({"uri": RESOURCE_URI_WINDOWS}),
10309 )
10310 .await;
10311 assert!(
10312 body.contains("\"contents\"") && !body.contains("not permitted"),
10313 "{body}"
10314 );
10315 }
10316
10317 #[tokio::test]
10320 async fn empty_auth_token_collapses_to_no_auth() {
10321 use http_body_util::BodyExt;
10322 use tower::ServiceExt;
10323
10324 for token in [Some(String::new()), Some(" ".to_string())] {
10325 let app = crate::mcp::server::build_app_full(
10326 state_with(PrivacyConfig::default()),
10327 Arc::new(RecordingBridge::default()),
10328 token.clone(),
10329 None,
10330 );
10331 let req = axum::extract::Request::builder()
10332 .uri("/info")
10333 .header("host", "127.0.0.1")
10334 .body(axum::body::Body::empty())
10335 .unwrap();
10336 let resp = app.oneshot(req).await.unwrap();
10337 assert_eq!(
10338 resp.status(),
10339 200,
10340 "/info must be reachable with empty token {token:?} (no auth layer)"
10341 );
10342 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
10343 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10344 assert_eq!(
10345 body["auth_required"],
10346 json!(false),
10347 "empty/whitespace token must report auth_required:false, not looks-protected-isnt (audit B2); token={token:?}"
10348 );
10349 }
10350 }
10351
10352 #[test]
10355 fn is_safe_env_key_drops_secrets_keeps_safe() {
10356 for secret in [
10357 "VICTAURI_AUTH_TOKEN",
10358 "TAURI_SIGNING_PRIVATE_KEY",
10359 "TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
10360 "CARGO_REGISTRY_TOKEN",
10361 "AWS_SECRET_ACCESS_KEY",
10362 "DATABASE_DSN",
10363 "GH_PAT",
10364 ] {
10365 assert!(
10366 !is_safe_env_key(secret),
10367 "{secret} is secret-shaped and must NOT be surfaced by app_info (audit #5)"
10368 );
10369 }
10370 for safe in [
10371 "HOME",
10372 "LANG",
10373 "TERM",
10374 "XDG_RUNTIME_DIR",
10375 "TAURI_ENV_PLATFORM",
10376 ] {
10377 assert!(
10378 is_safe_env_key(safe),
10379 "{safe} should be surfaced by app_info"
10380 );
10381 }
10382 }
10383}
10384
10385#[cfg(test)]
10392mod screenshot_visibility_tests {
10393 use super::*;
10394 use crate::bridge::WebviewBridge;
10395 use crate::privacy::PrivacyConfig;
10396 use std::collections::HashMap;
10397 use std::sync::Mutex as StdMutex;
10398 use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
10399
10400 fn window(label: &str, visible: bool) -> WindowState {
10401 WindowState::new(label.to_string())
10402 .with_title(label.to_string())
10403 .with_url("http://localhost/".to_string())
10404 .with_visible(visible)
10405 .with_focused(false)
10406 .with_maximized(false)
10407 .with_minimized(false)
10408 .with_fullscreen(false)
10409 .with_position(0, 0)
10410 .with_size(800, 600)
10411 }
10412
10413 struct ConfigBridge {
10419 windows: Vec<WindowState>,
10420 handle_label: Arc<StdMutex<Option<Option<String>>>>,
10421 }
10422
10423 impl ConfigBridge {
10424 fn new(windows: Vec<WindowState>) -> Self {
10425 Self {
10426 windows,
10427 handle_label: Arc::new(StdMutex::new(None)),
10428 }
10429 }
10430 fn requested_handle(&self) -> Option<Option<String>> {
10434 self.handle_label
10435 .lock()
10436 .unwrap_or_else(std::sync::PoisonError::into_inner)
10437 .clone()
10438 }
10439 }
10440
10441 impl WebviewBridge for ConfigBridge {
10442 fn eval_webview(&self, _l: Option<&str>, _s: &str) -> Result<(), String> {
10443 Err("no eval".to_string())
10444 }
10445 fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
10446 match label {
10447 Some(l) => self
10448 .windows
10449 .iter()
10450 .filter(|w| w.label == l)
10451 .cloned()
10452 .collect(),
10453 None => self.windows.clone(),
10454 }
10455 }
10456 fn list_window_labels(&self) -> Vec<String> {
10457 self.windows.iter().map(|w| w.label.clone()).collect()
10458 }
10459 fn get_native_handle(&self, l: Option<&str>) -> Result<isize, String> {
10460 *self
10461 .handle_label
10462 .lock()
10463 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(l.map(str::to_string));
10464 Err("native handle path reached".to_string())
10465 }
10466 fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
10467 Ok(String::new())
10468 }
10469 fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
10470 Ok(())
10471 }
10472 fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
10473 Ok(())
10474 }
10475 fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
10476 Ok(())
10477 }
10478 }
10479
10480 fn handler_with(bridge: Arc<ConfigBridge>) -> VictauriMcpHandler {
10481 let state = Arc::new(VictauriState {
10482 event_log: EventLog::new(100),
10483 registry: CommandRegistry::new(),
10484 port: std::sync::atomic::AtomicU16::new(0),
10485 pending_evals: Arc::new(Mutex::new(HashMap::new())),
10486 recorder: EventRecorder::new(100),
10487 privacy: PrivacyConfig::default(),
10488 eval_timeout: std::time::Duration::from_millis(100),
10489 shutdown_tx: tokio::sync::watch::channel(false).0,
10490 started_at: std::time::Instant::now(),
10491 tool_invocations: std::sync::atomic::AtomicU64::new(0),
10492 allow_file_navigation: false,
10493 command_timings: crate::introspection::CommandTimings::new(),
10494 fault_registry: crate::introspection::FaultRegistry::new(),
10495 contract_store: crate::introspection::ContractStore::new(),
10496 startup_timeline: crate::introspection::StartupTimeline::new(),
10497 event_bus: crate::introspection::EventBusMonitor::default(),
10498 task_tracker: crate::introspection::TaskTracker::new(),
10499 bridge_ready: std::sync::atomic::AtomicBool::new(true),
10500 bridge_notify: tokio::sync::Notify::new(),
10501 db_search_paths: Vec::new(),
10502 screencast: Arc::new(crate::screencast::Screencast::default()),
10503 probes: crate::introspection::AppStateProbes::default(),
10504 drain_watermarks: crate::introspection::DrainWatermarks::default(),
10505 page_loads: crate::introspection::PageLoads::default(),
10506 server_runtime: std::sync::OnceLock::new(),
10507 backend_logs: std::sync::Arc::new(crate::backend_logs::LogBuffer::default()),
10508 });
10509 VictauriMcpHandler::new(state, bridge)
10510 }
10511
10512 fn error_text(r: &CallToolResult) -> String {
10513 r.content
10514 .iter()
10515 .filter_map(|c| match c {
10516 ContentBlock::Text(t) => Some(t.text.clone()),
10517 _ => None,
10518 })
10519 .collect::<Vec<_>>()
10520 .join("\n")
10521 }
10522
10523 #[tokio::test]
10524 async fn hidden_window_screenshot_errors_clearly() {
10525 let bridge = Arc::new(ConfigBridge::new(vec![
10526 window("main", true),
10527 window("briefing", false),
10528 ]));
10529 let h = handler_with(bridge.clone());
10530 let r = h
10531 .screenshot(Parameters(ScreenshotParams {
10532 window_label: Some("briefing".to_string()),
10533 }))
10534 .await;
10535 assert_eq!(r.is_error, Some(true), "hidden window must error");
10536 let text = error_text(&r);
10537 assert!(
10538 text.contains("not visible"),
10539 "error must explain the window is not visible, got: {text}"
10540 );
10541 assert!(
10542 bridge.requested_handle().is_none(),
10543 "must short-circuit BEFORE the OS-handle/capture path"
10544 );
10545 }
10546
10547 #[tokio::test]
10548 async fn visible_window_screenshot_proceeds_to_capture() {
10549 let bridge = Arc::new(ConfigBridge::new(vec![
10550 window("main", true),
10551 window("briefing", false),
10552 ]));
10553 let h = handler_with(bridge.clone());
10554 let r = h
10555 .screenshot(Parameters(ScreenshotParams {
10556 window_label: Some("main".to_string()),
10557 }))
10558 .await;
10559 let text = error_text(&r);
10562 assert!(
10563 text.contains("native handle path reached")
10564 || text.contains("cannot get window handle"),
10565 "a visible window must reach the capture path, got: {text}"
10566 );
10567 assert_eq!(
10568 bridge.requested_handle(),
10569 Some(Some("main".to_string())),
10570 "must capture the explicitly requested visible window"
10571 );
10572 }
10573
10574 #[tokio::test]
10579 async fn omitted_label_skips_hidden_main_for_visible_secondary() {
10580 let bridge = Arc::new(ConfigBridge::new(vec![
10581 window("main", false), window("secondary", true), ]));
10584 let h = handler_with(bridge.clone());
10585 let r = h
10586 .screenshot(Parameters(ScreenshotParams { window_label: None }))
10587 .await;
10588 let text = error_text(&r);
10589 assert!(
10590 !text.contains("not visible") && !text.contains("no visible window"),
10591 "a visible secondary window exists — must NOT error, got: {text}"
10592 );
10593 assert_eq!(
10594 bridge.requested_handle(),
10595 Some(Some("secondary".to_string())),
10596 "omitted label must resolve to the VISIBLE secondary, never hidden main"
10597 );
10598 }
10599
10600 #[tokio::test]
10602 async fn omitted_label_prefers_visible_main() {
10603 let bridge = Arc::new(ConfigBridge::new(vec![
10604 window("main", true),
10605 window("secondary", true),
10606 ]));
10607 let h = handler_with(bridge.clone());
10608 let _ = h
10609 .screenshot(Parameters(ScreenshotParams { window_label: None }))
10610 .await;
10611 assert_eq!(
10612 bridge.requested_handle(),
10613 Some(Some("main".to_string())),
10614 "with a visible main present, omitted label must resolve to main"
10615 );
10616 }
10617
10618 #[tokio::test]
10620 async fn all_hidden_omitted_label_errors() {
10621 let bridge = Arc::new(ConfigBridge::new(vec![
10622 window("main", false),
10623 window("briefing", false),
10624 ]));
10625 let h = handler_with(bridge.clone());
10626 let r = h
10627 .screenshot(Parameters(ScreenshotParams { window_label: None }))
10628 .await;
10629 assert_eq!(r.is_error, Some(true), "all-hidden must error");
10630 assert!(
10631 error_text(&r).contains("no visible window"),
10632 "error must say there is no visible window, got: {}",
10633 error_text(&r)
10634 );
10635 assert!(
10636 bridge.requested_handle().is_none(),
10637 "must NOT reach the OS-handle path when every window is hidden"
10638 );
10639 }
10640
10641 #[tokio::test]
10644 async fn unknown_label_falls_through_to_handle_resolution() {
10645 let bridge = Arc::new(ConfigBridge::new(vec![window("main", true)]));
10646 let h = handler_with(bridge.clone());
10647 let r = h
10648 .screenshot(Parameters(ScreenshotParams {
10649 window_label: Some("ghost".to_string()),
10650 }))
10651 .await;
10652 assert!(
10653 !error_text(&r).contains("not visible"),
10654 "unknown label must not be reported as 'not visible'"
10655 );
10656 assert_eq!(
10657 bridge.requested_handle(),
10658 Some(Some("ghost".to_string())),
10659 "unknown label must be forwarded verbatim to get_native_handle"
10660 );
10661 }
10662}