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