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