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